Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a88118349 | ||
|
|
e07bf35cd0 | ||
|
|
a1147a98d2 | ||
|
|
f67fb7db31 | ||
|
|
7d4e89ced9 | ||
|
|
b9ee1bd225 | ||
|
|
68886b62fe | ||
|
|
293a41c7d4 | ||
|
|
35a859529f | ||
|
|
55ab800542 |
No files matched your search
@@ -35,6 +35,7 @@ artcraft-services/
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
|
||||
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
|
||||
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (Vite)
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
└── Cargo.toml # Rust workspace
|
||||
```
|
||||
@@ -96,6 +97,18 @@ Organize for top-to-bottom reading. Important things first, details later.
|
||||
- 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
|
||||
|
||||
## Public API Compatibility
|
||||
|
||||
- Public HTTP endpoints have deployed legacy clients. Preserve their request and
|
||||
response schemas, defaults, limits, and pagination behavior when optimizing them.
|
||||
- Put a changed contract behind a new endpoint/version and update new clients to
|
||||
call it explicitly. Do not add a new mode or change response fields on an existing
|
||||
public endpoint as part of a performance fix.
|
||||
- For an endpoint-local revision, version the operation path (for example,
|
||||
`/v1/media_files/list_v2/user/{username}`). Reserve a top-level `/v2` for broader
|
||||
API changes.
|
||||
- Add regression tests for the original contract alongside tests for the new route.
|
||||
|
||||
## Markdown
|
||||
|
||||
- **Tables must be space-padded so columns align in plain text.** Markdown
|
||||
|
||||
Generated
+9
@@ -313,6 +313,7 @@ dependencies = [
|
||||
"actix-http",
|
||||
"actix-rt",
|
||||
"actix-web",
|
||||
"domains",
|
||||
"log",
|
||||
"server_environment",
|
||||
"speculoos",
|
||||
@@ -2688,6 +2689,13 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "domains"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
@@ -8715,6 +8723,7 @@ dependencies = [
|
||||
"datetimes",
|
||||
"derive_more 0.99.17",
|
||||
"diesel",
|
||||
"domains",
|
||||
"easyenv",
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
|
||||
@@ -67,6 +67,7 @@ members = [
|
||||
"crates/lib/files/zip_archives",
|
||||
"crates/lib/http_headers",
|
||||
"crates/lib/jobs_common",
|
||||
"crates/lib/domains",
|
||||
"crates/lib/jwt_light",
|
||||
"crates/lib/opaque_cursors",
|
||||
"crates/lib/jwt_signer",
|
||||
@@ -252,6 +253,7 @@ grok_api_client = { path = "crates/api_clients/grok_api_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
|
||||
domains = { path = "crates/lib/domains" }
|
||||
http_headers = { path = "crates/lib/http_headers" }
|
||||
images = { path = "crates/lib/files/images" }
|
||||
jobs_common = { path = "crates/lib/jobs_common" }
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::cover_image_links::CoverImageLinks;
|
||||
use crate::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
|
||||
|
||||
// User-media list endpoints retain the deprecated bucket fields for compatibility.
|
||||
/// 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)]
|
||||
pub struct LegacyMediaFileCoverImageDetails {
|
||||
/// (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<String>,
|
||||
|
||||
/// (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<Url>,
|
||||
|
||||
/// 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<CoverImageLinks>,
|
||||
|
||||
/// For items without a cover image, we can use one of our own.
|
||||
pub default_cover: MediaFileDefaultCover,
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
/// Fields useful for enriching media file listings
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileOriginDetails {
|
||||
/// Where the file came from (broadly)
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
/// Where the file came from (specifically, a product area, eg. "face_animator")
|
||||
pub product_category: MediaFileOriginProductCategory,
|
||||
|
||||
/// If the file was produced by a model, the details.
|
||||
pub maybe_model: Option<MediaFileModelDetails>,
|
||||
}
|
||||
|
||||
/// Information about the model weights
|
||||
/// https://serde.rs/enum-representations.html#untagged
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum MediaFileModelDetails {
|
||||
// Try the specific shape first so deserialization retains token and title.
|
||||
/// A model weight is typically a user-submitted model.
|
||||
/// We store lots of these.
|
||||
ModelWeight {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
/// The model token
|
||||
token: ModelWeightToken,
|
||||
/// The model title (typically only populated for `model_weights` models, not legacy tables such as `tts_models`.)
|
||||
title: String,
|
||||
},
|
||||
/// A system model is one that we maintain and update,
|
||||
/// eg. SadTalker, Wav2Lip, MocapNet, etc.
|
||||
SystemModel {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod cover_image_links;
|
||||
pub mod job_details;
|
||||
pub mod legacy_media_file_cover_image_details;
|
||||
pub mod media_file_cover_image_details;
|
||||
pub mod media_file_origin_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;
|
||||
pub mod user_details_light;
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// 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)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct SimpleEntityStats {
|
||||
/// Number of positive ratings (or "likes") for this item
|
||||
pub positive_rating_count: u32,
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
|
||||
pub const LIST_MEDIA_FILES_FOR_USER_V2_URL_PATH: &str = "/v1/media_files/list_v2/user/{username}";
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2PathInfo {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesForUserV2QueryParams {
|
||||
pub sort_ascending: Option<bool>,
|
||||
pub page_size: Option<usize>,
|
||||
pub page_index: Option<usize>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
|
||||
/// which are the broad category of media files: image, video, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_classes=audio`
|
||||
/// - `?filter_media_classes=image,video`
|
||||
/// - `?filter_media_classes=dimensional`
|
||||
/// - etc.
|
||||
pub filter_media_classes: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
|
||||
/// which are mimetype-like / format-like categories of media files: glb, gltf,
|
||||
/// scene_json, jpg, png, mp4, wav, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_type=scene_json`
|
||||
/// - `?filter_media_type=glb,gltf,fbx`
|
||||
/// - `?filter_media_type=pmd,vmd,pmx`
|
||||
/// - `?filter_media_type=jpg,png,gif`
|
||||
/// - `?filter_media_type=wav,mp3`
|
||||
/// - `?filter_media_type=mp4`
|
||||
/// - etc.
|
||||
pub filter_media_type: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_engine_categories=scene`
|
||||
/// - `?filter_engine_categories=animation,character,object`
|
||||
/// - etc.
|
||||
pub filter_engine_categories: Option<String>,
|
||||
|
||||
/// Include user uploaded files in the results.
|
||||
/// By default, we do not return them unless this flag is set to true.
|
||||
pub include_user_uploads: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2SuccessResponse {
|
||||
pub success: bool,
|
||||
pub results: Vec<MediaFileForUserListItem>,
|
||||
pub pagination: UserMediaFileV2Pagination,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserMediaFileV2Pagination {
|
||||
pub current: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod by_type;
|
||||
pub mod list_media_files_for_user_v2;
|
||||
pub mod user_media_file_list_item;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
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_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
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 crate::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use crate::common::responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
use crate::common::responses::simple_entity_stats::SimpleEntityStats;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileForUserListItem {
|
||||
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<MediaFileEngineCategory>,
|
||||
|
||||
/// 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<MediaFileAnimationType>,
|
||||
|
||||
/// Details where the media file came from.
|
||||
pub origin: MediaFileOriginDetails,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
/// (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: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// The foreign key to the prompt used to generate the media, if applicable.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// 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: LegacyMediaFileCoverImageDetails,
|
||||
|
||||
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<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
/// Duration for audio and video files, if available.
|
||||
/// Measured in milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
+14
-11
@@ -8,6 +8,7 @@ use enums::common::view_as::ViewAs;
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use mysql_queries::queries::media_files::delete::delete_media_file::delete_media_file_as_mod;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
use crate::cli_args::Args;
|
||||
@@ -42,23 +43,25 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
|
||||
info!("Querying page {page_index} of files...");
|
||||
|
||||
let media_files = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
page_size: 100,
|
||||
page_index,
|
||||
filters: UserMediaFileFilters {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
view_as: ViewAs::Moderator,
|
||||
},
|
||||
limit: 100,
|
||||
offset: page_index * 100,
|
||||
sort_ascending: false,
|
||||
view_as: ViewAs::Moderator,
|
||||
mysql_pool: &mysql,
|
||||
mysql_executor: mysql,
|
||||
}).await?;
|
||||
|
||||
if media_files.records.is_empty() {
|
||||
if media_files.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for file in media_files.records {
|
||||
for file in media_files {
|
||||
info!("Deleting file: {:?}", &file.token);
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ normal = ["workspace-build-acceleration"]
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
domains.workspace = true
|
||||
server_environment.workspace = true
|
||||
|
||||
# External
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_2D;
|
||||
use domains::storyteller::STORYTELLER_2D;
|
||||
|
||||
pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://2d.storyteller.ai")
|
||||
.allowed_origin("https://2d.getartcraft.com")
|
||||
.allowed_origin(STORYTELLER_2D.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_2D.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-2d.netlify.app")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_3D;
|
||||
use domains::storyteller::STORYTELLER_3D;
|
||||
|
||||
pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://3d.storyteller.ai")
|
||||
.allowed_origin("https://3d.getartcraft.com")
|
||||
.allowed_origin(STORYTELLER_3D.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_3D.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-3d.netlify.app")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use actix_cors::Cors;
|
||||
use domains::artcraft::ARTCRAFT_DESKTOP;
|
||||
|
||||
pub fn add_artcraft_desktop(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Stable identity sent by the Artcraft native HTTP bridge (dev and packaged apps).
|
||||
.allowed_origin("https://desktop.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_DESKTOP.https_link_without_path())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_WEBAPP;
|
||||
|
||||
pub fn add_artcraft_webapp(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domains
|
||||
.allowed_origin("https://app.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_WEBAPP.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "artcraft-webapp.netlify.app")
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_AI;
|
||||
use domains::artcraft::ARTCRAFT_AI_WWW;
|
||||
use domains::artcraft::ARTCRAFT_WEBSITE;
|
||||
use domains::artcraft::ARTCRAFT_WEBSITE_WWW;
|
||||
|
||||
pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domains
|
||||
.allowed_origin("https://getartcraft.com")
|
||||
.allowed_origin("https://www.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_WEBSITE.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_WEBSITE_WWW.https_link_without_path())
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://artcraft.ai")
|
||||
.allowed_origin("https://www.artcraft.ai")
|
||||
.allowed_origin(ARTCRAFT_AI.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_AI_WWW.https_link_without_path())
|
||||
// Development against production
|
||||
.allowed_origin("http://localhost:4200")
|
||||
.allowed_origin("http://localhost:4201")
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
use actix_cors::Cors;
|
||||
use log::warn;
|
||||
use url::{Host, Url};
|
||||
use domains::fakeyou::FAKEYOU;
|
||||
use domains::fakeyou::FAKEYOU_API;
|
||||
use domains::fakeyou::FAKEYOU_ENGINE;
|
||||
use domains::fakeyou::FAKEYOU_STAGING;
|
||||
|
||||
pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
|
||||
if is_production {
|
||||
cors
|
||||
// Storyteller Engine (Production)
|
||||
.allowed_origin("https://engine.fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path())
|
||||
// FakeYou (Production)
|
||||
.allowed_origin("https://api.fakeyou.com")
|
||||
.allowed_origin("https://fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_API.https_link_without_path())
|
||||
.allowed_origin(FAKEYOU.https_link_without_path())
|
||||
// FakeYou (Staging)
|
||||
.allowed_origin("https://staging.fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_STAGING.https_link_without_path())
|
||||
// Allow Netlify domains within "fakeyou" project.
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
let maybe_url = origin.to_str()
|
||||
@@ -50,7 +54,7 @@ pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
|
||||
.allowed_origin("https://dev.fakeyou.com:7000") // Yarn default port
|
||||
.allowed_origin("https://dev.fakeyou.com:7001") // NB: Mac frontend
|
||||
// Storyteller Engine (Development)
|
||||
.allowed_origin("https://engine.fakeyou.com") // NB: We use prod for integration testing
|
||||
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path()) // NB: We use prod for integration testing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER;
|
||||
use domains::storyteller::STORYTELLER_API;
|
||||
use domains::storyteller::STORYTELLER_ENGINE;
|
||||
use domains::storyteller::STORYTELLER_STAGING;
|
||||
use domains::storyteller::STORYTELLER_STUDIO;
|
||||
|
||||
pub fn add_storyteller(cors: Cors, is_production: bool) -> Cors {
|
||||
if is_production {
|
||||
cors
|
||||
// Storyteller Engine (Production)
|
||||
.allowed_origin("https://engine.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_ENGINE.https_link_without_path())
|
||||
// Storyteller Studio (Production)
|
||||
.allowed_origin("https://studio.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
|
||||
// Storyteller.ai (Production)
|
||||
.allowed_origin("https://api.storyteller.ai")
|
||||
.allowed_origin("https://storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_API.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER.https_link_without_path())
|
||||
// Storyteller.ai (Staging)
|
||||
.allowed_origin("https://staging.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_STAGING.https_link_without_path())
|
||||
// Allow Netlify domains within "storyteller-ai" project.
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-ai.netlify.app")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_BOARD;
|
||||
|
||||
pub fn add_storyteller_board(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://memeboard.ai")
|
||||
.allowed_origin("https://dingboard.ai")
|
||||
.allowed_origin("https://board.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_BOARD.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-board.netlify.app")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_RENDER;
|
||||
|
||||
pub fn add_storyteller_render(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://render.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_RENDER.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-render.netlify.app")
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_ANIMATE;
|
||||
use domains::storyteller::STORYTELLER_STUDIO;
|
||||
use domains::storyteller::STORYTELLER_STUDIO_STAGING;
|
||||
use domains::storyteller::STORYTELLER_STUDIO_TESTING;
|
||||
|
||||
pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors.allowed_origin("https://studio.storyteller.ai")
|
||||
.allowed_origin("https://studio-staging.studio.storyteller.ai")
|
||||
.allowed_origin("https://studio-testing.studio.storyteller.ai")
|
||||
cors.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER_STUDIO_STAGING.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER_STUDIO_TESTING.https_link_without_path())
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storytellerstudio.netlify.app")
|
||||
})
|
||||
@@ -13,7 +17,7 @@ pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "pipeline-gottagofast.netlify.app")
|
||||
})
|
||||
.allowed_origin("http://localhost:5173")
|
||||
.allowed_origin("https://animate.storyteller.ai") // NB: Gen2 Studio
|
||||
.allowed_origin(STORYTELLER_ANIMATE.https_link_without_path()) // NB: Gen2 Studio
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "domains"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "domains"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Adds `Domain::https_url()`, returning a cached `&'static url::Url`.
|
||||
url = ["dep:url"]
|
||||
|
||||
# No dependencies by default: the domain constants and their string forms are
|
||||
# built entirely at compile time.
|
||||
[dependencies]
|
||||
url = { workspace = true, optional = true }
|
||||
@@ -0,0 +1,69 @@
|
||||
//! ArtCraft domains.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Public marketing website (Next.js).
|
||||
pub const ARTCRAFT_WEBSITE: Domain = domain!("getartcraft.com");
|
||||
pub const ARTCRAFT_WEBSITE_WWW: Domain = domain!("www.getartcraft.com");
|
||||
|
||||
/// Browser web application.
|
||||
pub const ARTCRAFT_WEBAPP: Domain = domain!("app.getartcraft.com");
|
||||
|
||||
/// Desktop app landing page.
|
||||
pub const ARTCRAFT_DESKTOP: Domain = domain!("desktop.getartcraft.com");
|
||||
|
||||
/// HTTP API.
|
||||
pub const ARTCRAFT_API: Domain = domain!("api.getartcraft.com");
|
||||
|
||||
pub const ARTCRAFT_2D: Domain = domain!("2d.getartcraft.com");
|
||||
pub const ARTCRAFT_3D: Domain = domain!("3d.getartcraft.com");
|
||||
|
||||
/// Alternate marketing domain.
|
||||
pub const ARTCRAFT_AI: Domain = domain!("artcraft.ai");
|
||||
pub const ARTCRAFT_AI_WWW: Domain = domain!("www.artcraft.ai");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(ARTCRAFT_WEBSITE.https_link_without_path(), "https://getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_WEBSITE_WWW.https_link_without_path(), "https://www.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp() {
|
||||
assert_eq!(ARTCRAFT_WEBAPP.https_link_without_path(), "https://app.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop() {
|
||||
assert_eq!(ARTCRAFT_DESKTOP.bare_domain(), "desktop.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_link_with_root_path(), "https://desktop.getartcraft.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(ARTCRAFT_API.https_link_without_path(), "https://api.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_d_and_three_d() {
|
||||
assert_eq!(ARTCRAFT_2D.https_link_without_path(), "https://2d.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_3D.https_link_without_path(), "https://3d.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artcraft_ai() {
|
||||
assert_eq!(ARTCRAFT_AI.https_link_without_path(), "https://artcraft.ai");
|
||||
assert_eq!(ARTCRAFT_AI_WWW.https_link_without_path(), "https://www.artcraft.ai");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
#[test]
|
||||
fn desktop_url() {
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_url().as_str(), "https://desktop.getartcraft.com/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/// A validated domain name, with its `https://` link forms precomputed at
|
||||
/// compile time. Build one with the [`domain!`](crate::domain) macro:
|
||||
///
|
||||
/// ```
|
||||
/// use domains::{domain, Domain};
|
||||
///
|
||||
/// const EXAMPLE: Domain = domain!("desktop.example.com");
|
||||
///
|
||||
/// assert_eq!(EXAMPLE.bare_domain(), "desktop.example.com");
|
||||
/// assert_eq!(EXAMPLE.https_link_without_path(), "https://desktop.example.com");
|
||||
/// assert_eq!(EXAMPLE.https_link_with_root_path(), "https://desktop.example.com/");
|
||||
/// ```
|
||||
///
|
||||
/// An invalid literal fails to compile:
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// const BAD: domains::Domain = domains::domain!("https://example.com");
|
||||
/// ```
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Domain {
|
||||
bare: &'static str,
|
||||
https_without_path: &'static str,
|
||||
https_with_root_path: &'static str,
|
||||
}
|
||||
|
||||
/// Declares a [`Domain`] from a string literal. Validation runs at compile
|
||||
/// time: an invalid domain is a build error, never a runtime panic.
|
||||
#[macro_export]
|
||||
macro_rules! domain {
|
||||
($domain:literal) => {
|
||||
$crate::Domain::__from_macro(
|
||||
$domain,
|
||||
::core::concat!("https://", $domain),
|
||||
::core::concat!("https://", $domain, "/"),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
impl Domain {
|
||||
/// Only for the `domain!` macro, which guarantees the three strings agree.
|
||||
/// Panics (a compile error in const context) on an invalid domain.
|
||||
#[doc(hidden)]
|
||||
pub const fn __from_macro(
|
||||
bare: &'static str,
|
||||
https_without_path: &'static str,
|
||||
https_with_root_path: &'static str,
|
||||
) -> Self {
|
||||
crate::validate::assert_valid_domain(bare);
|
||||
Self { bare, https_without_path, https_with_root_path }
|
||||
}
|
||||
|
||||
/// eg. `desktop.getartcraft.com`
|
||||
pub const fn bare_domain(&self) -> &'static str {
|
||||
self.bare
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com`. This is also the browser `Origin`
|
||||
/// header value, so use it for CORS and origin allowlists.
|
||||
pub const fn https_link_without_path(&self) -> &'static str {
|
||||
self.https_without_path
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com/`
|
||||
pub const fn https_link_with_root_path(&self) -> &'static str {
|
||||
self.https_with_root_path
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com/` as a `Url`.
|
||||
///
|
||||
/// Parsed on first use per domain, then served from a process-wide cache,
|
||||
/// so every call returns the same `&'static Url`.
|
||||
#[cfg(feature = "url")]
|
||||
pub fn https_url(&self) -> &'static url::Url {
|
||||
crate::url_cache::https_url(self.bare, self.https_with_root_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Domain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Domain({})", self.bare)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Domain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.bare)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Domain;
|
||||
|
||||
const TEST_DOMAIN: Domain = domain!("desktop.example.com");
|
||||
|
||||
#[test]
|
||||
fn bare_domain() {
|
||||
assert_eq!(TEST_DOMAIN.bare_domain(), "desktop.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_links() {
|
||||
assert_eq!(TEST_DOMAIN.https_link_without_path(), "https://desktop.example.com");
|
||||
assert_eq!(TEST_DOMAIN.https_link_with_root_path(), "https://desktop.example.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_in_const_context() {
|
||||
const ORIGINS: &[&str] = &[TEST_DOMAIN.https_link_without_path()];
|
||||
assert_eq!(ORIGINS, &["https://desktop.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_debug() {
|
||||
assert_eq!(TEST_DOMAIN.to_string(), "desktop.example.com");
|
||||
assert_eq!(format!("{TEST_DOMAIN:?}"), "Domain(desktop.example.com)");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
mod url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_url_has_root_path() {
|
||||
let url = TEST_DOMAIN.https_url();
|
||||
assert_eq!(url.as_str(), "https://desktop.example.com/");
|
||||
assert_eq!(url.scheme(), "https");
|
||||
assert_eq!(url.host_str(), Some("desktop.example.com"));
|
||||
assert_eq!(url.path(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_is_parsed_once_and_shared() {
|
||||
let first = TEST_DOMAIN.https_url();
|
||||
let second = TEST_DOMAIN.https_url();
|
||||
assert!(std::ptr::eq(first, second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_is_shared_across_threads() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Domain>();
|
||||
assert_send_sync::<&'static url::Url>();
|
||||
|
||||
// Borrowed into 'static threads with no Arc or cloning; every thread
|
||||
// sees the same parsed instance.
|
||||
let main_thread_url = TEST_DOMAIN.https_url();
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| std::thread::spawn(|| TEST_DOMAIN.https_url()))
|
||||
.collect();
|
||||
for handle in handles {
|
||||
assert!(std::ptr::eq(handle.join().unwrap(), main_thread_url));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_matches_string_forms() {
|
||||
let url = TEST_DOMAIN.https_url();
|
||||
assert_eq!(url.as_str(), TEST_DOMAIN.https_link_with_root_path());
|
||||
assert_eq!(url.origin().ascii_serialization(), TEST_DOMAIN.https_link_without_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! FakeYou domains.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Main website.
|
||||
pub const FAKEYOU: Domain = domain!("fakeyou.com");
|
||||
|
||||
/// HTTP API.
|
||||
pub const FAKEYOU_API: Domain = domain!("api.fakeyou.com");
|
||||
|
||||
/// Engine frontend (also used against production for integration testing).
|
||||
pub const FAKEYOU_ENGINE: Domain = domain!("engine.fakeyou.com");
|
||||
|
||||
pub const FAKEYOU_STAGING: Domain = domain!("staging.fakeyou.com");
|
||||
|
||||
/// Local development hosts (resolve to a developer machine).
|
||||
pub const FAKEYOU_DEV: Domain = domain!("dev.fakeyou.com");
|
||||
pub const FAKEYOU_DEV_PROXY: Domain = domain!("devproxy.fakeyou.com");
|
||||
|
||||
/// Production media CDN. Serves both FakeYou and Storyteller media.
|
||||
pub const FAKEYOU_CDN: Domain = domain!("cdn-2.fakeyou.com");
|
||||
|
||||
/// Development media CDN (a Cloudflare R2 public bucket). Serves both FakeYou
|
||||
/// and Storyteller development media, like `FAKEYOU_CDN` does in production.
|
||||
pub const FAKEYOU_DEV_CDN: Domain = domain!("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(FAKEYOU.bare_domain(), "fakeyou.com");
|
||||
assert_eq!(FAKEYOU.https_link_without_path(), "https://fakeyou.com");
|
||||
assert_eq!(FAKEYOU.https_link_with_root_path(), "https://fakeyou.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(FAKEYOU_API.https_link_without_path(), "https://api.fakeyou.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_and_staging() {
|
||||
assert_eq!(FAKEYOU_ENGINE.https_link_without_path(), "https://engine.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_STAGING.https_link_without_path(), "https://staging.fakeyou.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(FAKEYOU_DEV.bare_domain(), "dev.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_DEV_PROXY.bare_domain(), "devproxy.fakeyou.com");
|
||||
}
|
||||
|
||||
mod cdn {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production() {
|
||||
assert_eq!(FAKEYOU_CDN.bare_domain(), "cdn-2.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_CDN.https_link_without_path(), "https://cdn-2.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_CDN.https_link_with_root_path(), "https://cdn-2.fakeyou.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(FAKEYOU_DEV_CDN.https_link_without_path(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
#[test]
|
||||
fn urls() {
|
||||
assert_eq!(FAKEYOU_CDN.https_url().as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(FAKEYOU_DEV_CDN.https_url().as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! First-party domain names, validated at compile time.
|
||||
//!
|
||||
//! ```
|
||||
//! use domains::artcraft::ARTCRAFT_DESKTOP;
|
||||
//!
|
||||
//! assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
|
||||
//! ```
|
||||
//!
|
||||
//! Enable the `url` feature for `Domain::https_url()`, a cached `&'static url::Url`.
|
||||
|
||||
pub mod artcraft;
|
||||
pub mod fakeyou;
|
||||
pub mod storyteller;
|
||||
|
||||
mod domain;
|
||||
mod validate;
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
mod url_cache;
|
||||
|
||||
pub use domain::Domain;
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Storyteller domains. Storyteller media is served from `fakeyou::FAKEYOU_CDN`.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Main website.
|
||||
pub const STORYTELLER: Domain = domain!("storyteller.ai");
|
||||
|
||||
/// HTTP API.
|
||||
pub const STORYTELLER_API: Domain = domain!("api.storyteller.ai");
|
||||
|
||||
pub const STORYTELLER_ENGINE: Domain = domain!("engine.storyteller.ai");
|
||||
pub const STORYTELLER_STAGING: Domain = domain!("staging.storyteller.ai");
|
||||
|
||||
/// Storyteller Studio and its deploy environments.
|
||||
pub const STORYTELLER_STUDIO: Domain = domain!("studio.storyteller.ai");
|
||||
pub const STORYTELLER_STUDIO_STAGING: Domain = domain!("studio-staging.studio.storyteller.ai");
|
||||
pub const STORYTELLER_STUDIO_TESTING: Domain = domain!("studio-testing.studio.storyteller.ai");
|
||||
|
||||
/// Gen2 Studio.
|
||||
pub const STORYTELLER_ANIMATE: Domain = domain!("animate.storyteller.ai");
|
||||
|
||||
pub const STORYTELLER_BOARD: Domain = domain!("board.storyteller.ai");
|
||||
pub const STORYTELLER_RENDER: Domain = domain!("render.storyteller.ai");
|
||||
|
||||
/// Legacy hosts for the ArtCraft 2D and 3D apps.
|
||||
pub const STORYTELLER_2D: Domain = domain!("2d.storyteller.ai");
|
||||
pub const STORYTELLER_3D: Domain = domain!("3d.storyteller.ai");
|
||||
|
||||
/// Local development hosts (resolve to a developer machine).
|
||||
pub const STORYTELLER_DEV: Domain = domain!("dev.storyteller.ai");
|
||||
pub const STORYTELLER_DEV_PROXY: Domain = domain!("devproxy.storyteller.ai");
|
||||
|
||||
/// Storyteller Studio static assets.
|
||||
pub const STORYTELLER_CDN: Domain = domain!("cdn.storyteller.ai");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(STORYTELLER.bare_domain(), "storyteller.ai");
|
||||
assert_eq!(STORYTELLER.https_link_without_path(), "https://storyteller.ai");
|
||||
assert_eq!(STORYTELLER.https_link_with_root_path(), "https://storyteller.ai/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(STORYTELLER_API.https_link_without_path(), "https://api.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio() {
|
||||
assert_eq!(STORYTELLER_STUDIO.https_link_without_path(), "https://studio.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_STUDIO_STAGING.https_link_without_path(), "https://studio-staging.studio.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_STUDIO_TESTING.https_link_without_path(), "https://studio-testing.studio.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps() {
|
||||
assert_eq!(STORYTELLER_BOARD.https_link_without_path(), "https://board.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_RENDER.https_link_without_path(), "https://render.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_2D.https_link_without_path(), "https://2d.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_3D.https_link_without_path(), "https://3d.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(STORYTELLER_DEV.bare_domain(), "dev.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_DEV_PROXY.bare_domain(), "devproxy.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn() {
|
||||
assert_eq!(STORYTELLER_CDN.https_link_without_path(), "https://cdn.storyteller.ai");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Process-wide cache backing `Domain::https_url()`.
|
||||
//!
|
||||
//! `Url::parse` can't run at compile time, and `Domain` stays free of
|
||||
//! interior mutability so its constants remain usable in `const` contexts.
|
||||
//! So each domain's `Url` is parsed on first use and leaked into this map.
|
||||
//! Domains only come from `domain!` literals, so the set (and the leak) is
|
||||
//! bounded by the constants in the source code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use url::Url;
|
||||
|
||||
static URLS: Mutex<BTreeMap<&'static str, &'static Url>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
pub(crate) fn https_url(bare: &'static str, https_with_root_path: &'static str) -> &'static Url {
|
||||
// The map is only ever inserted into, so a poisoned lock still holds
|
||||
// valid entries.
|
||||
let mut urls = URLS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
urls.entry(bare).or_insert_with(|| {
|
||||
// The domain was validated at compile time, so this parse can't fail.
|
||||
let url = Url::parse(https_with_root_path).expect("compile-time validated domain should parse");
|
||||
Box::leak(Box::new(url))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Compile-time domain name validation.
|
||||
//!
|
||||
//! `assert_valid_domain` is a `const fn` that panics on an invalid name. The
|
||||
//! `domain!` macro calls it while evaluating a `const`/`static`, where a panic
|
||||
//! is a compile error, so a bad domain literal never builds.
|
||||
|
||||
const MAX_DOMAIN_LEN: usize = 253;
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
|
||||
/// Panics (a compile error in const context) unless `domain` is a canonical,
|
||||
/// fully-qualified DNS name: lowercase `a-z`, `0-9` and `-` labels separated
|
||||
/// by single dots, at least two labels, no scheme, port, path or trailing dot,
|
||||
/// and a non-numeric top-level label (so IP addresses are rejected).
|
||||
pub const fn assert_valid_domain(domain: &str) {
|
||||
match check_domain(domain) {
|
||||
Ok(()) => {}
|
||||
Err(DomainError::Empty) => panic!("invalid domain: empty"),
|
||||
Err(DomainError::TooLong) => panic!("invalid domain: longer than 253 bytes"),
|
||||
Err(DomainError::EmptyLabel) => panic!("invalid domain: empty label (leading, trailing or doubled dot)"),
|
||||
Err(DomainError::LabelTooLong) => panic!("invalid domain: label longer than 63 bytes"),
|
||||
Err(DomainError::InvalidCharacter) => panic!("invalid domain: only lowercase a-z, 0-9, '-' and '.' are allowed"),
|
||||
Err(DomainError::HyphenAtLabelEdge) => panic!("invalid domain: label starts or ends with '-'"),
|
||||
Err(DomainError::SingleLabel) => panic!("invalid domain: needs at least two labels (eg. example.com)"),
|
||||
Err(DomainError::NumericTopLevelLabel) => panic!("invalid domain: top-level label is numeric (IP addresses are not domains)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DomainError {
|
||||
Empty,
|
||||
TooLong,
|
||||
EmptyLabel,
|
||||
LabelTooLong,
|
||||
InvalidCharacter,
|
||||
HyphenAtLabelEdge,
|
||||
SingleLabel,
|
||||
NumericTopLevelLabel,
|
||||
}
|
||||
|
||||
pub(crate) const fn check_domain(domain: &str) -> Result<(), DomainError> {
|
||||
let bytes = domain.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return Err(DomainError::Empty);
|
||||
}
|
||||
if bytes.len() > MAX_DOMAIN_LEN {
|
||||
return Err(DomainError::TooLong);
|
||||
}
|
||||
|
||||
let mut labels = 0;
|
||||
let mut label_start = 0;
|
||||
let mut last_label_start = 0;
|
||||
let mut i = 0;
|
||||
// Walk one past the end so the final label is checked like the others.
|
||||
while i <= bytes.len() {
|
||||
if i == bytes.len() || bytes[i] == b'.' {
|
||||
match check_label(bytes, label_start, i) {
|
||||
Ok(()) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
labels += 1;
|
||||
last_label_start = label_start;
|
||||
label_start = i + 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if labels < 2 {
|
||||
return Err(DomainError::SingleLabel);
|
||||
}
|
||||
if is_all_digits(bytes, last_label_start, bytes.len()) {
|
||||
return Err(DomainError::NumericTopLevelLabel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks `bytes[start..end]` as a single label.
|
||||
const fn check_label(bytes: &[u8], start: usize, end: usize) -> Result<(), DomainError> {
|
||||
let len = end - start;
|
||||
if len == 0 {
|
||||
return Err(DomainError::EmptyLabel);
|
||||
}
|
||||
if len > MAX_LABEL_LEN {
|
||||
return Err(DomainError::LabelTooLong);
|
||||
}
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
match bytes[i] {
|
||||
b'a'..=b'z' | b'0'..=b'9' | b'-' => {}
|
||||
_ => return Err(DomainError::InvalidCharacter),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if bytes[start] == b'-' || bytes[end - 1] == b'-' {
|
||||
return Err(DomainError::HyphenAtLabelEdge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn is_all_digits(bytes: &[u8], start: usize, end: usize) -> bool {
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
if !bytes[i].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DomainError::*;
|
||||
use super::check_domain;
|
||||
|
||||
mod valid {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apex_domain() {
|
||||
assert_eq!(check_domain("getartcraft.com"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subdomains() {
|
||||
assert_eq!(check_domain("desktop.getartcraft.com"), Ok(()));
|
||||
assert_eq!(check_domain("studio-staging.studio.storyteller.ai"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_and_hyphens_inside_labels() {
|
||||
assert_eq!(check_domain("cdn-2.fakeyou.com"), Ok(()));
|
||||
assert_eq!(check_domain("3d.getartcraft.com"), Ok(()));
|
||||
assert_eq!(check_domain("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lengths() {
|
||||
let label_63 = "a".repeat(63);
|
||||
assert_eq!(check_domain(&format!("{label_63}.com")), Ok(()));
|
||||
|
||||
// 63 + 1 + 63 + 1 + 63 + 1 + 61 = 253
|
||||
let domain_253 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(61));
|
||||
assert_eq!(domain_253.len(), 253);
|
||||
assert_eq!(check_domain(&domain_253), Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
mod invalid {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
assert_eq!(check_domain(""), Err(Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_long() {
|
||||
let label_63 = "a".repeat(63);
|
||||
let domain_254 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(62));
|
||||
assert_eq!(check_domain(&domain_254), Err(TooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_labels() {
|
||||
assert_eq!(check_domain(".getartcraft.com"), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("getartcraft.com."), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("desktop..getartcraft.com"), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("."), Err(EmptyLabel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_too_long() {
|
||||
assert_eq!(check_domain(&format!("{}.com", "a".repeat(64))), Err(LabelTooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase() {
|
||||
assert_eq!(check_domain("GetArtCraft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_port_path_and_whitespace() {
|
||||
assert_eq!(check_domain("https://getartcraft.com"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain("getartcraft.com:443"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain("getartcraft.com/"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain(" getartcraft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii() {
|
||||
assert_eq!(check_domain("artcräft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscore() {
|
||||
assert_eq!(check_domain("dev_proxy.fakeyou.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphen_at_label_edge() {
|
||||
assert_eq!(check_domain("-desktop.getartcraft.com"), Err(HyphenAtLabelEdge));
|
||||
assert_eq!(check_domain("desktop-.getartcraft.com"), Err(HyphenAtLabelEdge));
|
||||
assert_eq!(check_domain("getartcraft.com-"), Err(HyphenAtLabelEdge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_label() {
|
||||
assert_eq!(check_domain("localhost"), Err(SingleLabel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_addresses() {
|
||||
assert_eq!(check_domain("127.0.0.1"), Err(NumericTopLevelLabel));
|
||||
assert_eq!(check_domain("example.123"), Err(NumericTopLevelLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use super::user_media_file_filters::UserMediaFileFilters;
|
||||
|
||||
pub struct CountMediaFilesForUserArgs<'a, T> {
|
||||
pub filters: UserMediaFileFilters<'a>,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Legacy numbered pagination needs an exact total. Count only this user's rows,
|
||||
/// without related-table lookups or global low-selectivity index intersections.
|
||||
pub async fn count_media_files_for_user<'c, T>(args: CountMediaFilesForUserArgs<'_, T>) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let mut query =
|
||||
QueryBuilder::<MySql>::new("SELECT COUNT(*) FROM media_files m FORCE INDEX (fk_maybe_creator_user_token)");
|
||||
args.filters.push_predicates(&mut query);
|
||||
query.build_query_scalar().fetch_one(args.mysql_executor).await
|
||||
}
|
||||
+49
-438
@@ -1,191 +1,10 @@
|
||||
use std::collections::HashSet;
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
|
||||
use super::user_media_file_filters::UserMediaFileFilters;
|
||||
use super::user_media_file_list_item::{MediaFileListItem, MediaFileListItemInternal};
|
||||
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
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_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
|
||||
|
||||
pub struct MediaFileListPage {
|
||||
pub records: Vec<MediaFileListItem>,
|
||||
|
||||
pub sort_ascending: bool,
|
||||
|
||||
pub current_page: usize,
|
||||
pub total_page_count: usize,
|
||||
}
|
||||
|
||||
pub struct MediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
pub maybe_origin_model_title: Option<String>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
pub maybe_prompt_args: Option<PromptInnerPayload>,
|
||||
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListMediaFileForUserArgs<'a> {
|
||||
pub username: &'a str,
|
||||
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
|
||||
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
|
||||
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
|
||||
pub include_user_uploads: bool,
|
||||
pub page_size: usize,
|
||||
pub page_index: usize,
|
||||
pub sort_ascending: bool,
|
||||
pub view_as: ViewAs,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult<MediaFileListPage> {
|
||||
/// Let's figure out how many results we could have returned total
|
||||
let count_fields = select_total_count_field();
|
||||
let mut count_query_builder = query_builder(
|
||||
args.maybe_filter_media_types,
|
||||
args.maybe_filter_media_classes,
|
||||
args.maybe_filter_engine_categories,
|
||||
args.include_user_uploads,
|
||||
args.username,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
count_fields.as_str(),
|
||||
true,
|
||||
);
|
||||
|
||||
let row_count_query = count_query_builder.build_query_scalar::<i64>();
|
||||
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
|
||||
|
||||
/// Now fetch the actual results with all the fields
|
||||
let result_fields = select_result_fields();
|
||||
let mut query = query_builder(
|
||||
args.maybe_filter_media_types,
|
||||
args.maybe_filter_media_classes,
|
||||
args.maybe_filter_engine_categories,
|
||||
args.include_user_uploads,
|
||||
args.username,
|
||||
true,
|
||||
args.page_index,
|
||||
args.page_size,
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
result_fields.as_str(),
|
||||
false,
|
||||
);
|
||||
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
maybe_origin_model_title: record.maybe_origin_model_title,
|
||||
public_bucket_directory_hash: record.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: i8_to_bool(record.is_user_upload),
|
||||
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
|
||||
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
|
||||
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
|
||||
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_prompt_args: record.maybe_other_prompt_args
|
||||
.as_deref()
|
||||
.map(|args| PromptInnerPayload::from_json(args))
|
||||
.transpose()
|
||||
.ok() // NB: Fail open
|
||||
.flatten(),
|
||||
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
|
||||
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: record.maybe_bookmark_count,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(MediaFileListPage {
|
||||
records: results,
|
||||
sort_ascending: args.sort_ascending,
|
||||
current_page: args.page_index,
|
||||
total_page_count: number_of_pages,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_result_fields() -> String {
|
||||
r#"
|
||||
const RESULT_FIELDS: &str = r#"
|
||||
m.id,
|
||||
m.token,
|
||||
|
||||
@@ -229,258 +48,50 @@ fn select_result_fields() -> String {
|
||||
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
"#
|
||||
.to_string()
|
||||
"#;
|
||||
|
||||
pub struct ListMediaFileForUserArgs<'a, T> {
|
||||
pub filters: UserMediaFileFilters<'a>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
pub sort_ascending: bool,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
fn select_total_count_field() -> String {
|
||||
r#"
|
||||
COUNT(m.id) AS total_count
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn query_builder<'a>(
|
||||
maybe_filter_media_types: Option<&HashSet<MediaFileType>>,
|
||||
maybe_filter_media_classes: Option<&HashSet<MediaFileClass>>,
|
||||
maybe_filter_engine_categories: Option<&HashSet<MediaFileEngineCategory>>,
|
||||
include_user_uploads: bool,
|
||||
username: &'a str,
|
||||
enforce_limits: bool,
|
||||
page_index: usize,
|
||||
page_size: usize,
|
||||
sort_ascending: bool,
|
||||
view_as: ViewAs,
|
||||
select_fields: &'a str,
|
||||
count_only: bool,
|
||||
) -> QueryBuilder<'a, MySql> {
|
||||
|
||||
// NB: The count query only needs the `users` join (for the username predicate).
|
||||
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
|
||||
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
|
||||
let from_clause = if count_only {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
"#
|
||||
} else {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
LEFT OUTER JOIN model_weights as w
|
||||
ON m.maybe_origin_model_token = w.token
|
||||
LEFT OUTER JOIN media_files as media_file_cover_image
|
||||
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
LEFT OUTER JOIN entity_stats
|
||||
ON entity_stats.entity_type = "media_file"
|
||||
AND entity_stats.entity_token = m.token
|
||||
LEFT OUTER JOIN prompts
|
||||
ON prompts.token = m.maybe_prompt_token
|
||||
"#
|
||||
};
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
{from_clause}"#
|
||||
));
|
||||
|
||||
query_builder.push(" WHERE u.username = ");
|
||||
query_builder.push_bind(username);
|
||||
|
||||
//if let Some(media_type) = maybe_filter_media_type {
|
||||
// // FIXME: Binding shouldn't require to_str().
|
||||
// // Otherwise, it's calling the Display trait on the raw type which is resulting in an
|
||||
// // incorrect binding and runtime error.
|
||||
// query_builder.push(" AND m.media_type = ");
|
||||
// query_builder.push_bind(media_type.to_str());
|
||||
//}
|
||||
|
||||
if let Some(media_types) = maybe_filter_media_types {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !media_types.is_empty() {
|
||||
query_builder.push(" AND m.media_type IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for media_type in media_types.iter() {
|
||||
separated.push_bind(media_type.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(media_classes) = maybe_filter_media_classes {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !media_classes.is_empty() {
|
||||
query_builder.push(" AND m.media_class IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for media_class in media_classes.iter() {
|
||||
separated.push_bind(media_class.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(engine_categories) = maybe_filter_engine_categories {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !engine_categories.is_empty() {
|
||||
query_builder.push(" AND m.maybe_engine_category IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for engine_category in engine_categories.iter() {
|
||||
separated.push_bind(engine_category.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if !include_user_uploads {
|
||||
query_builder.push(" AND NOT m.is_user_upload ");
|
||||
}
|
||||
|
||||
// TODO: Argument to control
|
||||
query_builder.push(" AND NOT m.is_intermediate_system_file ");
|
||||
|
||||
match view_as {
|
||||
ViewAs::Author | ViewAs::Moderator => {
|
||||
// NB(bt): Actually, mods don't want to see deleted files. We'll improve the moderator UI later.
|
||||
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
|
||||
}
|
||||
ViewAs::AnotherUser => {
|
||||
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
|
||||
// FIXME: Binding shouldn't require to_str().
|
||||
// Otherwise, it's calling the Display trait on the raw type which is resulting in an
|
||||
// incorrect binding and runtime error.
|
||||
query_builder.push(" AND m.creator_set_visibility = ");
|
||||
query_builder.push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Ordering is meaningless for the single-row COUNT aggregate.
|
||||
if !count_only {
|
||||
if sort_ascending {
|
||||
query_builder.push(" ORDER BY m.created_at ASC ");
|
||||
} else {
|
||||
query_builder.push(" ORDER BY m.created_at DESC ");
|
||||
}
|
||||
}
|
||||
|
||||
if enforce_limits {
|
||||
let offset = page_index * page_size;
|
||||
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
|
||||
}
|
||||
|
||||
query_builder
|
||||
}
|
||||
|
||||
struct MediaFileListItemInternal {
|
||||
id: i64,
|
||||
token: MediaFileToken,
|
||||
|
||||
media_class: MediaFileClass,
|
||||
media_type: MediaFileType,
|
||||
|
||||
maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
origin_category: MediaFileOriginCategory,
|
||||
origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
maybe_origin_model_title: Option<String>,
|
||||
maybe_other_prompt_args: Option<String>,
|
||||
maybe_duration_millis: Option<i32>,
|
||||
|
||||
public_bucket_directory_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
is_user_upload: i8,
|
||||
is_intermediate_system_file: i8,
|
||||
|
||||
maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
maybe_text_transcript: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
|
||||
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
|
||||
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
|
||||
// cannot have type hints, eg. the following:
|
||||
//
|
||||
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
|
||||
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
//
|
||||
// This results in the automatic mapping not being able to be found by name (for macro derive), and
|
||||
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
|
||||
// full "as" clause).
|
||||
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: MediaFileToken::new(row.try_get("token")?),
|
||||
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
|
||||
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
|
||||
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
|
||||
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
|
||||
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
|
||||
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
|
||||
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
|
||||
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
|
||||
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
|
||||
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
|
||||
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
is_user_upload: row.try_get("is_user_upload")?,
|
||||
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
|
||||
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
|
||||
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
|
||||
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
|
||||
maybe_title: row.try_get("maybe_title")?,
|
||||
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
/// Select a bounded page before joining potentially large prompt/metadata rows.
|
||||
/// `LIMIT` prevents MySQL from merging the derived table into the outer query.
|
||||
pub async fn list_media_files_for_user<'c, T>(
|
||||
args: ListMediaFileForUserArgs<'_, T>,
|
||||
) -> Result<Vec<MediaFileListItem>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let order = if args.sort_ascending { " ASC" } else { " DESC" };
|
||||
let mut query = QueryBuilder::<MySql>::new(format!("SELECT {RESULT_FIELDS} FROM (SELECT m.id "));
|
||||
// This index is in the July 2026 migrations. Restricting the scan to this user
|
||||
// avoids index-merge scans of the global deletion/class indexes on large tables.
|
||||
query.push("FROM media_files m FORCE INDEX (idx_creator_created_at) ");
|
||||
args.filters.push_predicates(&mut query);
|
||||
query.push(" ORDER BY m.created_at").push(order).push(", m.id").push(order);
|
||||
query.push(" LIMIT ").push_bind(args.limit as u64);
|
||||
query.push(" OFFSET ").push_bind(args.offset as u64);
|
||||
query.push(
|
||||
r#") AS page
|
||||
JOIN media_files m ON m.id = page.id
|
||||
LEFT JOIN model_weights w ON m.maybe_origin_model_token = w.token
|
||||
LEFT JOIN media_files media_file_cover_image ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
LEFT JOIN entity_stats ON entity_stats.entity_type = "#,
|
||||
);
|
||||
query.push_bind(StatsEntityType::MediaFile.to_str());
|
||||
query
|
||||
.push(
|
||||
r#" AND entity_stats.entity_token = m.token
|
||||
LEFT JOIN prompts ON prompts.token = m.maybe_prompt_token
|
||||
ORDER BY m.created_at"#,
|
||||
)
|
||||
.push(order)
|
||||
.push(", m.id")
|
||||
.push(order);
|
||||
let records = query.build_query_as::<MediaFileListItemInternal>().fetch_all(args.mysql_executor).await?;
|
||||
Ok(records.into_iter().map(MediaFileListItem::from).collect())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod count_media_files_for_user;
|
||||
pub mod list_batch_generated_redux_media_files;
|
||||
pub mod list_featured_media_files;
|
||||
pub mod list_media_files;
|
||||
@@ -7,3 +8,5 @@ pub mod list_media_files_for_user;
|
||||
pub mod list_session_media_files_by_class;
|
||||
pub mod list_session_project_media_files;
|
||||
pub mod media_file_list_row;
|
||||
pub mod user_media_file_filters;
|
||||
pub mod user_media_file_list_item;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use sqlx::{MySql, QueryBuilder};
|
||||
|
||||
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::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
|
||||
/// Shared predicates keep the optional total and the returned page consistent.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UserMediaFileFilters<'a> {
|
||||
pub username: &'a str,
|
||||
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
|
||||
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
|
||||
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
|
||||
pub include_user_uploads: bool,
|
||||
pub view_as: ViewAs,
|
||||
}
|
||||
|
||||
impl<'a> UserMediaFileFilters<'a> {
|
||||
pub(super) fn push_predicates(&self, query: &mut QueryBuilder<'a, MySql>) {
|
||||
// Resolve the unique username once; subsequent scans are scoped to its token.
|
||||
query.push(" WHERE m.maybe_creator_user_token = (SELECT token FROM users WHERE username = ");
|
||||
query.push_bind(self.username).push(")");
|
||||
if let Some(values) = self.maybe_filter_media_types.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.media_type IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if let Some(values) = self.maybe_filter_media_classes.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.media_class IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if let Some(values) = self.maybe_filter_engine_categories.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.maybe_engine_category IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if !self.include_user_uploads {
|
||||
query.push(" AND NOT m.is_user_upload");
|
||||
}
|
||||
query.push(" AND NOT m.is_intermediate_system_file AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL");
|
||||
if self.view_as == ViewAs::AnotherUser {
|
||||
query.push(" AND m.creator_set_visibility = ").push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
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_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
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 tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
|
||||
|
||||
pub struct MediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
pub maybe_origin_model_title: Option<String>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
pub maybe_prompt_args: Option<PromptInnerPayload>,
|
||||
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub(super) struct MediaFileListItemInternal {
|
||||
id: i64,
|
||||
token: MediaFileToken,
|
||||
|
||||
media_class: MediaFileClass,
|
||||
media_type: MediaFileType,
|
||||
|
||||
maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
origin_category: MediaFileOriginCategory,
|
||||
origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
maybe_origin_model_title: Option<String>,
|
||||
maybe_other_prompt_args: Option<String>,
|
||||
maybe_duration_millis: Option<i32>,
|
||||
|
||||
public_bucket_directory_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
is_user_upload: i8,
|
||||
is_intermediate_system_file: i8,
|
||||
|
||||
maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
maybe_text_transcript: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
|
||||
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
|
||||
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
|
||||
// cannot have type hints, eg. the following:
|
||||
//
|
||||
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
|
||||
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
//
|
||||
// This results in the automatic mapping not being able to be found by name (for macro derive), and
|
||||
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
|
||||
// full "as" clause).
|
||||
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: MediaFileToken::new(row.try_get("token")?),
|
||||
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
|
||||
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
|
||||
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
|
||||
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
|
||||
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
|
||||
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
|
||||
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
|
||||
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
|
||||
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
|
||||
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
|
||||
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
is_user_upload: row.try_get("is_user_upload")?,
|
||||
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
|
||||
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
|
||||
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
|
||||
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
|
||||
maybe_title: row.try_get("maybe_title")?,
|
||||
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MediaFileListItemInternal> for MediaFileListItem {
|
||||
fn from(record: MediaFileListItemInternal) -> Self {
|
||||
MediaFileListItem {
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
maybe_origin_model_title: record.maybe_origin_model_title,
|
||||
public_bucket_directory_hash: record.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: i8_to_bool(record.is_user_upload),
|
||||
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
|
||||
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
|
||||
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
|
||||
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_prompt_args: record.maybe_other_prompt_args
|
||||
.as_deref()
|
||||
.map(|args| PromptInnerPayload::from_json(args))
|
||||
.transpose()
|
||||
.ok() // NB: Fail open
|
||||
.flatten(),
|
||||
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
|
||||
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: record.maybe_bookmark_count,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Executes the actual library queries against migrated, disposable local MySQL.
|
||||
//! IsolatedTestDatabase uses a fixed Unix socket, never DATABASE_URL/MYSQL_URL.
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
|
||||
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_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::media_files::list::count_media_files_for_user::{
|
||||
count_media_files_for_user, CountMediaFilesForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
|
||||
list_media_files_for_user, ListMediaFileForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use mysql_testing::fixtures::users::create_test_user;
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use sqlx::{MySql, MySqlPool, QueryBuilder};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
const CREATED_AT: &str = "2025-01-01 12:00:00";
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_media_library_preserves_filters_visibility_joins_and_pagination() {
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let owner = create_test_user(&db.pool).await.unwrap();
|
||||
let other = create_test_user(&db.pool).await.unwrap();
|
||||
let rows = [
|
||||
("m_public_image", MediaFileClass::Image, MediaFileType::Png, Visibility::Public, true),
|
||||
("m_private_video", MediaFileClass::Video, MediaFileType::Mp4, Visibility::Private, false),
|
||||
("m_hidden_audio", MediaFileClass::Audio, MediaFileType::Mp3, Visibility::Hidden, false),
|
||||
("m_public_mesh", MediaFileClass::Mesh, MediaFileType::Glb, Visibility::Public, false),
|
||||
("m_private_splat", MediaFileClass::Splat, MediaFileType::Spz, Visibility::Private, false),
|
||||
("m_legacy_mesh", MediaFileClass::Dimensional, MediaFileType::Glb, Visibility::Public, false),
|
||||
];
|
||||
for (token, class, kind, visibility, upload) in rows {
|
||||
insert_fixture(&db.pool, Fixture { owner: &owner.user_token, token, class, kind, visibility, upload }).await;
|
||||
}
|
||||
for token in ["m_intermediate", "m_user_deleted", "m_mod_deleted"] {
|
||||
insert_fixture(&db.pool, Fixture::image(&owner.user_token, token)).await;
|
||||
}
|
||||
insert_fixture(&db.pool, Fixture::image(&other.user_token, "m_other_user")).await;
|
||||
sqlx::query("UPDATE media_files SET is_intermediate_system_file = true WHERE token = 'm_intermediate'")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_user_deleted'")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE media_files SET mod_deleted_at = NOW() WHERE token = 'm_mod_deleted'")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE media_files SET maybe_engine_category = ? WHERE token = 'm_public_mesh'")
|
||||
.bind(MediaFileEngineCategory::Object.to_str())
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO prompts (token, prompt_type, creator_ip_address, maybe_other_args) VALUES ('p_library', ?, '127.0.0.1', ?)")
|
||||
.bind(PromptType::ArtcraftApp.to_str()).bind(r#"{"fd":true}"#).execute(&db.pool).await.unwrap();
|
||||
sqlx::query("UPDATE media_files SET maybe_cover_image_media_file_token = 'm_intermediate', maybe_prompt_token = 'p_library', maybe_duration_millis = 1234, maybe_title = 'Fixture title' WHERE token = 'm_private_video'").execute(&db.pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO entity_stats (entity_type, entity_token, ratings_positive_count, bookmark_count) VALUES (?, 'm_private_video', 3, 7)")
|
||||
.bind(StatsEntityType::MediaFile.to_str()).execute(&db.pool).await.unwrap();
|
||||
|
||||
let filters = filters(&owner.username);
|
||||
for (view_as, expected) in [
|
||||
(
|
||||
ViewAs::Author,
|
||||
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
|
||||
),
|
||||
(
|
||||
ViewAs::Moderator,
|
||||
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
|
||||
),
|
||||
(ViewAs::AnotherUser, vec!["m_legacy_mesh", "m_public_mesh", "m_public_image"]),
|
||||
] {
|
||||
let selected = UserMediaFileFilters { view_as, ..filters };
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters: selected,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(records.iter().map(|r| r.token.as_str()).collect::<Vec<_>>(), expected);
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
|
||||
.await
|
||||
.unwrap(),
|
||||
expected.len() as i64
|
||||
);
|
||||
}
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: true,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(records.first().unwrap().token.as_str(), "m_public_image");
|
||||
let video = &records[1];
|
||||
assert_eq!(video.maybe_file_cover_image_public_bucket_hash.as_deref(), Some("fixture_hash"));
|
||||
assert_eq!(video.maybe_prompt_args.as_ref().unwrap().used_face_detailer, Some(true));
|
||||
assert_eq!(video.maybe_ratings_positive_count, Some(3));
|
||||
assert_eq!(video.maybe_bookmark_count, Some(7));
|
||||
assert_eq!(video.maybe_duration_millis, Some(1234));
|
||||
assert_eq!(video.maybe_title.as_deref(), Some("Fixture title"));
|
||||
|
||||
// All timestamps tie. The id tiebreaker must avoid duplicates/omissions.
|
||||
let mut paged = vec![];
|
||||
for offset in [0, 2, 4, 6] {
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 2,
|
||||
offset,
|
||||
sort_ascending: true,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
paged.extend(records.into_iter().map(|r| r.token.to_string()));
|
||||
}
|
||||
assert_eq!(paged, rows.iter().map(|r| r.0.to_string()).collect::<Vec<_>>());
|
||||
let classes = HashSet::from([MediaFileClass::Image, MediaFileClass::Video]);
|
||||
let types = HashSet::from([MediaFileType::Glb]);
|
||||
let engines = HashSet::from([MediaFileEngineCategory::Object]);
|
||||
let empty = HashSet::new();
|
||||
for (selected, expected) in [
|
||||
(UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters }, 2),
|
||||
(UserMediaFileFilters { maybe_filter_media_types: Some(&types), ..filters }, 2),
|
||||
(UserMediaFileFilters { maybe_filter_engine_categories: Some(&engines), ..filters }, 1),
|
||||
(UserMediaFileFilters { include_user_uploads: false, ..filters }, 5),
|
||||
(UserMediaFileFilters { maybe_filter_media_classes: Some(&empty), ..filters }, 6),
|
||||
(UserMediaFileFilters { username: "' OR 1=1 --", ..filters }, 0),
|
||||
] {
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters: selected,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(records.len(), expected);
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
|
||||
.await
|
||||
.unwrap(),
|
||||
expected as i64
|
||||
);
|
||||
}
|
||||
// Queries must honor an existing transaction, including uncommitted changes.
|
||||
let mut tx = db.pool.begin().await.unwrap();
|
||||
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_private_video'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *tx }).await.unwrap(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &mut *tx
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
5
|
||||
);
|
||||
tx.rollback().await.unwrap();
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &db.pool }).await.unwrap(),
|
||||
6
|
||||
);
|
||||
db.destroy().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "large local fixture; run explicitly with --ignored --nocapture"]
|
||||
async fn user_media_library_large_page_reads_are_bounded() {
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let owner = create_test_user(&db.pool).await.unwrap();
|
||||
let other = create_test_user(&db.pool).await.unwrap();
|
||||
// 60k owner rows, followed by 240k newer files from another account.
|
||||
for chunk in 0..300 {
|
||||
let mut query = QueryBuilder::<MySql>::new("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) ");
|
||||
query.push_values(0..1000, |mut row, index| {
|
||||
row
|
||||
.push_bind(format!("m_bench_{}", chunk * 1000 + index))
|
||||
.push_bind(MediaFileOriginCategory::Upload.to_str())
|
||||
.push_bind(MediaFileOriginProductCategory::Unknown.to_str())
|
||||
.push_bind(MediaFileType::Png.to_str())
|
||||
.push_bind(MediaFileClass::Image.to_str())
|
||||
.push_bind("0".repeat(64))
|
||||
.push_bind("fixture_hash")
|
||||
.push_bind("127.0.0.1")
|
||||
.push_bind(if chunk < 60 { owner.user_token.as_str() } else { other.user_token.as_str() })
|
||||
.push_bind(Visibility::Private.to_str())
|
||||
.push_bind(false)
|
||||
.push_bind(true)
|
||||
.push_bind(CREATED_AT);
|
||||
});
|
||||
query.build().execute(&db.pool).await.unwrap();
|
||||
}
|
||||
sqlx::query("ANALYZE TABLE media_files").execute(&db.pool).await.unwrap();
|
||||
let classes = HashSet::from([
|
||||
MediaFileClass::Image,
|
||||
MediaFileClass::Video,
|
||||
MediaFileClass::Audio,
|
||||
MediaFileClass::Dimensional,
|
||||
MediaFileClass::Mesh,
|
||||
MediaFileClass::Splat,
|
||||
]);
|
||||
let filters = UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters(&owner.username) };
|
||||
let mut conn = db.pool.acquire().await.unwrap();
|
||||
let before = scan_reads(&mut conn).await;
|
||||
let start = Instant::now();
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 61,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &mut *conn,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
let scanned = scan_reads(&mut conn).await - before;
|
||||
assert_eq!(records.len(), 61);
|
||||
assert_eq!(records.first().unwrap().token.as_str(), "m_bench_59999");
|
||||
assert!(scanned < 200, "page query scanned {scanned} sequential index entries");
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *conn }).await.unwrap(),
|
||||
60_000
|
||||
);
|
||||
println!(
|
||||
"300k fixtures / 60k owned: page={elapsed:?}, sequential index reads={scanned}; optional count={:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
drop(conn);
|
||||
db.destroy().await;
|
||||
}
|
||||
|
||||
struct Fixture<'a> {
|
||||
owner: &'a UserToken,
|
||||
token: &'a str,
|
||||
class: MediaFileClass,
|
||||
kind: MediaFileType,
|
||||
visibility: Visibility,
|
||||
upload: bool,
|
||||
}
|
||||
|
||||
impl<'a> Fixture<'a> {
|
||||
fn image(owner: &'a UserToken, token: &'a str) -> Self {
|
||||
Self {
|
||||
owner,
|
||||
token,
|
||||
class: MediaFileClass::Image,
|
||||
kind: MediaFileType::Png,
|
||||
visibility: Visibility::Public,
|
||||
upload: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filters(username: &str) -> UserMediaFileFilters<'_> {
|
||||
UserMediaFileFilters {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
view_as: ViewAs::Author,
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_fixture(pool: &MySqlPool, fixture: Fixture<'_>) {
|
||||
sqlx::query("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) VALUES (?, ?, ?, ?, ?, ?, 'fixture_hash', '127.0.0.1', ?, ?, false, ?, ?)")
|
||||
.bind(fixture.token).bind(MediaFileOriginCategory::Upload.to_str()).bind(MediaFileOriginProductCategory::Unknown.to_str())
|
||||
.bind(fixture.kind.to_str()).bind(fixture.class.to_str()).bind("0".repeat(64)).bind(fixture.owner.as_str())
|
||||
.bind(fixture.visibility.to_str()).bind(fixture.upload).bind(CREATED_AT).execute(pool).await.unwrap();
|
||||
}
|
||||
|
||||
async fn scan_reads(conn: &mut sqlx::MySqlConnection) -> u64 {
|
||||
let values: Vec<(String, String)> =
|
||||
sqlx::query_as("SHOW SESSION STATUS WHERE Variable_name IN ('Handler_read_next', 'Handler_read_prev')")
|
||||
.fetch_all(conn)
|
||||
.await
|
||||
.unwrap();
|
||||
values.into_iter().map(|(_, value)| value.parse::<u64>().unwrap()).sum()
|
||||
}
|
||||
@@ -34,6 +34,7 @@ workspace-build-acceleration.workspace = true
|
||||
# Internal
|
||||
actix_artcraft.workspace = true
|
||||
actix_cors_configs = { path = "../../../lib/actix_cors_configs" }
|
||||
domains = { workspace = true, features = ["url"] }
|
||||
actix_helpers.workspace = true
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_api_keys.workspace = true
|
||||
|
||||
@@ -378,6 +378,12 @@ use artcraft_api_defs::media_file::list::by_type::list_session_common::*;
|
||||
use artcraft_api_defs::media_file::job::list_media_files_by_job::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::{
|
||||
ListMediaFilesForUserV2PathInfo, ListMediaFilesForUserV2QueryParams,
|
||||
ListMediaFilesForUserV2SuccessResponse, UserMediaFileV2Pagination,
|
||||
};
|
||||
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
use artcraft_api_defs::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use artcraft_api_defs::media_file::list_session_project_media_files::*;
|
||||
use artcraft_api_defs::media_file::project::upload_updated_mood_board_project::*;
|
||||
use artcraft_api_defs::media_file::project::upload_new_mood_board_project::*;
|
||||
@@ -519,6 +525,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler,
|
||||
@@ -1140,6 +1147,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
InferTtsSuccessResponse,
|
||||
InferenceJobStatusResponsePayload,
|
||||
InferenceJobTokenType,
|
||||
LegacyMediaFileCoverImageDetails,
|
||||
LegacyQueueDetails,
|
||||
ListActiveUserSubscriptionsResponse,
|
||||
ListAvailableWeightsQuery,
|
||||
@@ -1182,6 +1190,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
ListMediaFilesForUserPathInfo,
|
||||
ListMediaFilesForUserQueryParams,
|
||||
ListMediaFilesForUserSuccessResponse,
|
||||
ListMediaFilesForUserV2PathInfo,
|
||||
ListMediaFilesForUserV2QueryParams,
|
||||
ListMediaFilesForUserV2SuccessResponse,
|
||||
UserMediaFileV2Pagination,
|
||||
ListMediaFilesQueryParams,
|
||||
ListMediaFilesSuccessResponse,
|
||||
ListPinnedMediaFilesSuccessResponse,
|
||||
|
||||
+42
-29
@@ -1,43 +1,56 @@
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use once_cell::sync::Lazy;
|
||||
use domains::Domain;
|
||||
use domains::fakeyou::{FAKEYOU_CDN, FAKEYOU_DEV_CDN};
|
||||
use server_environment::ServerEnvironment;
|
||||
use url::Url;
|
||||
|
||||
const FAKEYOU_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
|
||||
const FAKEYOU_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
const STORYTELLER_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
|
||||
|
||||
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const STORYTELLER_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELLER_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const FAKEYOU_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_DEVELOPMENT_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const STORYTELLER_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELLER_DEVELOPMENT_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
// NB: Storyteller media is served from the FakeYou CDN too.
|
||||
const FAKEYOU_PRODUCTION: Domain = FAKEYOU_CDN;
|
||||
const STORYTELLER_PRODUCTION: Domain = FAKEYOU_CDN;
|
||||
|
||||
const FAKEYOU_DEVELOPMENT: Domain = FAKEYOU_DEV_CDN;
|
||||
const STORYTELLER_DEVELOPMENT: Domain = FAKEYOU_DEV_CDN;
|
||||
|
||||
// TODO(bt,2025-01-31): Perhaps this should be config driven and configurable at runtime instead of hardcoded.
|
||||
pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnvironment) -> &'static str {
|
||||
match (media_domain, server_environment) {
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN_STR,
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN_STR,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Development) => STORYTELLER_DEVELOPMENT_CDN_STR,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Production) => STORYTELLER_CDN_STR,
|
||||
}
|
||||
cdn_domain(media_domain, server_environment).https_link_without_path()
|
||||
}
|
||||
|
||||
pub fn new_cdn_url(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Url {
|
||||
cdn_domain(media_domain, server_environment).https_url().clone()
|
||||
}
|
||||
|
||||
fn cdn_domain(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Domain {
|
||||
match (media_domain, server_environment) {
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN.clone(),
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN.clone(),
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Development) => STORYTELLER_DEVELOPMENT_CDN.clone(),
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Production) => STORYTELLER_CDN.clone(),
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT,
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_PRODUCTION,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Development) => STORYTELLER_DEVELOPMENT,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Production) => STORYTELLER_PRODUCTION,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::http_server::common_responses::media::cdn_link::{get_cdn_host, new_cdn_url};
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
const PROD_CDN: &str = "https://cdn-2.fakeyou.com";
|
||||
const DEV_CDN: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
|
||||
#[test]
|
||||
fn cdn_hosts() {
|
||||
assert_eq!(get_cdn_host(MediaDomain::FakeYou, ServerEnvironment::Production), PROD_CDN);
|
||||
assert_eq!(get_cdn_host(MediaDomain::Storyteller, ServerEnvironment::Production), PROD_CDN);
|
||||
assert_eq!(get_cdn_host(MediaDomain::FakeYou, ServerEnvironment::Development), DEV_CDN);
|
||||
assert_eq!(get_cdn_host(MediaDomain::Storyteller, ServerEnvironment::Development), DEV_CDN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn_urls() {
|
||||
assert_eq!(new_cdn_url(MediaDomain::FakeYou, ServerEnvironment::Production).as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(new_cdn_url(MediaDomain::Storyteller, ServerEnvironment::Production).as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(new_cdn_url(MediaDomain::FakeYou, ServerEnvironment::Development).as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
assert_eq!(new_cdn_url(MediaDomain::Storyteller, ServerEnvironment::Development).as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
}
|
||||
}
|
||||
+4
-16
@@ -1,17 +1,6 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use domains::fakeyou::FAKEYOU_CDN;
|
||||
use url::Url;
|
||||
|
||||
//const FAKEYOU_CDN_STR: &str = "https://storage.googleapis.com/dev-vocodes-public";
|
||||
const FAKEYOU_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
|
||||
const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
|
||||
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const STORYTELLER_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELLER_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
/// Which domain to generate CDN, etc. links for.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum MediaDomain {
|
||||
@@ -22,14 +11,13 @@ pub enum MediaDomain {
|
||||
impl MediaDomain {
|
||||
pub fn new_cdn_url(&self) -> Url {
|
||||
match self {
|
||||
MediaDomain::FakeYou => FAKEYOU_CDN.clone(),
|
||||
MediaDomain::Storyteller => STORYTELLER_CDN.clone(),
|
||||
// NB: Storyteller media is served from the FakeYou CDN too.
|
||||
MediaDomain::FakeYou | MediaDomain::Storyteller => FAKEYOU_CDN.https_url().clone(),
|
||||
}
|
||||
}
|
||||
pub fn cdn_url_str(&self) -> &'static str {
|
||||
match self {
|
||||
MediaDomain::FakeYou => FAKEYOU_CDN_STR,
|
||||
MediaDomain::Storyteller => STORYTELLER_CDN_STR,
|
||||
MediaDomain::FakeYou | MediaDomain::Storyteller => FAKEYOU_CDN.https_link_without_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use actix_web::HttpRequest;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use domains::artcraft::{ARTCRAFT_WEBAPP, ARTCRAFT_WEBSITE, ARTCRAFT_WEBSITE_WWW};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use rand::{
|
||||
rngs::{OsRng, StdRng},
|
||||
@@ -16,7 +17,11 @@ use sha2::{Digest, Sha256};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
const CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
|
||||
const APPROVAL_ORIGINS: &[&str] = &["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"];
|
||||
const APPROVAL_ORIGINS: &[&str] = &[
|
||||
ARTCRAFT_WEBAPP.https_link_without_path(),
|
||||
ARTCRAFT_WEBSITE.https_link_without_path(),
|
||||
ARTCRAFT_WEBSITE_WWW.https_link_without_path(),
|
||||
];
|
||||
const WINDOW: Duration = Duration::from_secs(60);
|
||||
const MAX_BUCKETS: usize = 10_000;
|
||||
|
||||
|
||||
+34
-204
@@ -1,36 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
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_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use log::{info, warn};
|
||||
use log::info;
|
||||
use std::time::Instant;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use mysql_queries::queries::media_files::list::count_media_files_for_user::{count_media_files_for_user, CountMediaFilesForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::http_server::common_responses::pagination_page::PaginationPage;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::allowed_studio_access::allowed_studio_access;
|
||||
|
||||
@@ -89,93 +75,6 @@ pub struct ListMediaFilesForUserSuccessResponse {
|
||||
pub pagination: PaginationPage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MediaFileForUserListItem {
|
||||
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<MediaFileEngineCategory>,
|
||||
|
||||
/// 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<MediaFileAnimationType>,
|
||||
|
||||
/// Details where the media file came from.
|
||||
pub origin: MediaFileOriginDetails,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
/// (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: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// The foreign key to the prompt used to generate the media, if applicable.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// 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,
|
||||
|
||||
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<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
/// Duration for audio and video files, if available.
|
||||
/// Measured in milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List all of a user's media files (paginated).
|
||||
///
|
||||
/// This endpoint uses the session to automatically show all files for the given user, but only
|
||||
@@ -238,117 +137,48 @@ pub async fn list_media_files_for_user_handler(
|
||||
let mut maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
|
||||
let mut maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
|
||||
|
||||
info!("Querying media files for user: {:?} type: {:?} as: {:?}", path.username, maybe_filter_media_types, view_as);
|
||||
|
||||
let query_results = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
let filters = UserMediaFileFilters {
|
||||
username: &path.username,
|
||||
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
|
||||
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
|
||||
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
|
||||
include_user_uploads: query.include_user_uploads.unwrap_or(false),
|
||||
page_size,
|
||||
page_index,
|
||||
sort_ascending,
|
||||
view_as,
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
}).await;
|
||||
|
||||
let results_page = match query_results {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
warn!("Query error: {:?}", e);
|
||||
return Err(CommonWebError::from_anyhow_error(e));
|
||||
}
|
||||
};
|
||||
let count_started = Instant::now();
|
||||
let total_count = count_media_files_for_user(CountMediaFilesForUserArgs {
|
||||
filters,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
}).await?;
|
||||
let count_ms = count_started.elapsed().as_millis();
|
||||
let page_started = Instant::now();
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: page_size,
|
||||
offset: page_index * page_size,
|
||||
sort_ascending,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
}).await?;
|
||||
info!("Listed legacy user media: username={} page={} count_ms={} page_ms={}",
|
||||
path.username, page_index, count_ms, page_started.elapsed().as_millis());
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let results = results_page.records.into_iter()
|
||||
.filter(|record| {
|
||||
if is_allowed_studio_access {
|
||||
return true;
|
||||
}
|
||||
// Don't allow access to certain media types.
|
||||
match record.media_type {
|
||||
MediaFileType::Bvh |
|
||||
MediaFileType::Fbx |
|
||||
MediaFileType::Glb |
|
||||
MediaFileType::Gltf |
|
||||
MediaFileType::SceneRon => return false,
|
||||
_ => {},
|
||||
}
|
||||
// // Don't allow access to certain products.
|
||||
// match record.origin_product_category {
|
||||
// MediaFileOriginProductCategory::VideoFilter |
|
||||
// MediaFileOriginProductCategory::Mocap |
|
||||
// MediaFileOriginProductCategory::Workflow => return false,
|
||||
// _ => {},
|
||||
// }
|
||||
true
|
||||
})
|
||||
.map(|record| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&record.public_bucket_directory_hash,
|
||||
record.maybe_public_bucket_prefix.as_deref(),
|
||||
record.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
MediaFileForUserListItem {
|
||||
token: record.token.clone(),
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin: MediaFileOriginDetails::from_db_fields_str(
|
||||
record.origin_category,
|
||||
record.origin_product_category,
|
||||
record.maybe_origin_model_type,
|
||||
record.maybe_origin_model_token.as_deref(),
|
||||
record.maybe_origin_model_title.as_deref()),
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain, server_state.server_environment, &public_bucket_path),
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
public_bucket_path: public_bucket_path
|
||||
.get_full_object_path_str()
|
||||
.to_string(),
|
||||
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_state.server_environment),
|
||||
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
),
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: record.is_user_upload,
|
||||
is_intermediate_system_file: record.is_intermediate_system_file,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_style_name: record.maybe_prompt_args
|
||||
.as_ref()
|
||||
.and_then(|args| args.style_name.as_ref())
|
||||
.and_then(|style| style.to_style_name()),
|
||||
maybe_duration_millis: record.maybe_duration_millis,
|
||||
stats: SimpleEntityStats {
|
||||
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
|
||||
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
|
||||
},
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = build_user_media_file_response(UserMediaFileResponseArgs {
|
||||
records,
|
||||
is_allowed_studio_access,
|
||||
media_domain,
|
||||
server_environment: server_state.server_environment,
|
||||
});
|
||||
|
||||
Ok(Json(ListMediaFilesForUserSuccessResponse {
|
||||
success: true,
|
||||
results,
|
||||
pagination: PaginationPage {
|
||||
current: results_page.current_page,
|
||||
total_page_count: results_page.total_page_count,
|
||||
current: page_index,
|
||||
// Preserve v1's historical calculation, including exact multiples.
|
||||
// A pagination behavior change belongs in a new endpoint version.
|
||||
total_page_count: 1 + (total_count / page_size as i64) as usize,
|
||||
}
|
||||
}))
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::{
|
||||
ListMediaFilesForUserV2PathInfo, ListMediaFilesForUserV2QueryParams,
|
||||
ListMediaFilesForUserV2SuccessResponse, UserMediaFileV2Pagination,
|
||||
};
|
||||
use enums::common::view_as::ViewAs;
|
||||
use log::info;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
|
||||
list_media_files_for_user, ListMediaFileForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
|
||||
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::allowed_studio_access::allowed_studio_access;
|
||||
|
||||
/// Faster user-media listing for infinite scroll.
|
||||
///
|
||||
/// Unlike `/v1/media_files/list/user/{username}`, this endpoint skips the exact
|
||||
/// `COUNT(*)` over the user's entire matching library. It fetches `page_size + 1`
|
||||
/// records to return `pagination.has_more`, without a total-page count. Removing
|
||||
/// that scan makes loading pages faster for large libraries.
|
||||
///
|
||||
/// Both endpoints use the same optimized per-user indexes and load related
|
||||
/// metadata only after selecting a page. The original endpoint retains its
|
||||
/// request/response contract and pagination behavior for deployed clients.
|
||||
///
|
||||
/// Page indexes remain offset based; very deep pages still scan preceding
|
||||
/// matching records. Page size defaults to 25 and must be between 1 and 100.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Media Files",
|
||||
path = "/v1/media_files/list_v2/user/{username}",
|
||||
params(ListMediaFilesForUserV2QueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Faster user-media page with has_more; no full-library count", body = ListMediaFilesForUserV2SuccessResponse),
|
||||
(status = 400, description = "Invalid pagination"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn list_media_files_for_user_v2_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListMediaFilesForUserV2PathInfo>,
|
||||
query: Query<ListMediaFilesForUserV2QueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFilesForUserV2SuccessResponse>, CommonWebError> {
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await?;
|
||||
|
||||
let mut is_author = false;
|
||||
let mut is_mod = false;
|
||||
|
||||
// NB: Temporary rollout flag for certain file types (BVH, etc).
|
||||
let is_allowed_studio_access = allowed_studio_access(maybe_user_session.as_ref(), &server_state.flags);
|
||||
|
||||
match maybe_user_session {
|
||||
None => {}
|
||||
Some(session) => {
|
||||
is_author = session.username == path.username;
|
||||
is_mod = session.can_ban_users;
|
||||
}
|
||||
};
|
||||
|
||||
let sort_ascending = query.sort_ascending.unwrap_or(false);
|
||||
let page_size = query.page_size.unwrap_or_else(|| 25);
|
||||
let page_index = query.page_index.unwrap_or_else(|| 0);
|
||||
if !(1..=100).contains(&page_size) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"page_size must be between 1 and 100".to_string(),
|
||||
));
|
||||
}
|
||||
let offset = page_index
|
||||
.checked_mul(page_size)
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("page_index is too large".to_string()))?;
|
||||
|
||||
let view_as = if is_author {
|
||||
ViewAs::Author
|
||||
} else if is_mod {
|
||||
ViewAs::Moderator
|
||||
} else {
|
||||
ViewAs::AnotherUser
|
||||
};
|
||||
|
||||
let maybe_filter_media_types = get_scoped_media_types(query.filter_media_type.as_deref());
|
||||
let maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
|
||||
let maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
|
||||
|
||||
let filters = UserMediaFileFilters {
|
||||
username: &path.username,
|
||||
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
|
||||
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
|
||||
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
|
||||
include_user_uploads: query.include_user_uploads.unwrap_or(false),
|
||||
view_as,
|
||||
};
|
||||
let page_started = Instant::now();
|
||||
let mut records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: page_size + 1,
|
||||
offset,
|
||||
sort_ascending,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
})
|
||||
.await?;
|
||||
let has_more = records.len() > page_size;
|
||||
records.truncate(page_size);
|
||||
info!(
|
||||
"Listed list_v2 user media: username={} page={} page_ms={}",
|
||||
path.username,
|
||||
page_index,
|
||||
page_started.elapsed().as_millis()
|
||||
);
|
||||
let results = build_user_media_file_response(UserMediaFileResponseArgs {
|
||||
records,
|
||||
is_allowed_studio_access,
|
||||
media_domain: get_media_domain(&http_request),
|
||||
server_environment: server_state.server_environment,
|
||||
});
|
||||
Ok(Json(ListMediaFilesForUserV2SuccessResponse {
|
||||
success: true,
|
||||
results,
|
||||
pagination: UserMediaFileV2Pagination {
|
||||
current: page_index,
|
||||
has_more,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -6,3 +6,5 @@ pub mod list_media_files_for_user_handler;
|
||||
pub mod list_media_files_handler;
|
||||
pub mod list_pinned_media_files_handler;
|
||||
pub mod list_session_project_media_files_handler;
|
||||
pub mod list_media_files_for_user_v2_handler;
|
||||
mod user_media_file_response;
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
use artcraft_api_defs::common::responses::cover_image_links::CoverImageLinks;
|
||||
use artcraft_api_defs::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use artcraft_api_defs::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
|
||||
use artcraft_api_defs::common::responses::media_file_origin_details::{
|
||||
MediaFileModelDetails as MediaFileModelDetailsApi, MediaFileOriginDetails as MediaFileOriginDetailsApi,
|
||||
};
|
||||
use artcraft_api_defs::common::responses::simple_entity_stats::SimpleEntityStats;
|
||||
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use mysql_queries::queries::media_files::list::user_media_file_list_item::MediaFileListItem;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::common_responses::media_file_origin_details::{MediaFileModelDetails, MediaFileOriginDetails};
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
|
||||
|
||||
pub(super) struct UserMediaFileResponseArgs {
|
||||
pub records: Vec<MediaFileListItem>,
|
||||
pub is_allowed_studio_access: bool,
|
||||
pub media_domain: MediaDomain,
|
||||
pub server_environment: ServerEnvironment,
|
||||
}
|
||||
|
||||
/// Shared media-item representation; pagination contracts remain versioned.
|
||||
pub(super) fn build_user_media_file_response(args: UserMediaFileResponseArgs) -> Vec<MediaFileForUserListItem> {
|
||||
let UserMediaFileResponseArgs {
|
||||
records,
|
||||
is_allowed_studio_access,
|
||||
media_domain,
|
||||
server_environment,
|
||||
} = args;
|
||||
records
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
if is_allowed_studio_access {
|
||||
return true;
|
||||
}
|
||||
// Don't allow access to certain media types.
|
||||
match record.media_type {
|
||||
MediaFileType::Bvh
|
||||
| MediaFileType::Fbx
|
||||
| MediaFileType::Glb
|
||||
| MediaFileType::Gltf
|
||||
| MediaFileType::SceneRon => return false,
|
||||
_ => {}
|
||||
}
|
||||
// // Don't allow access to certain products.
|
||||
// match record.origin_product_category {
|
||||
// MediaFileOriginProductCategory::VideoFilter |
|
||||
// MediaFileOriginProductCategory::Mocap |
|
||||
// MediaFileOriginProductCategory::Workflow => return false,
|
||||
// _ => {},
|
||||
// }
|
||||
true
|
||||
})
|
||||
.map(|record| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&record.public_bucket_directory_hash,
|
||||
record.maybe_public_bucket_prefix.as_deref(),
|
||||
record.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
MediaFileForUserListItem {
|
||||
token: record.token.clone(),
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin: into_api_origin(MediaFileOriginDetails::from_db_fields_str(
|
||||
record.origin_category,
|
||||
record.origin_product_category,
|
||||
record.maybe_origin_model_type,
|
||||
record.maybe_origin_model_token.as_deref(),
|
||||
record.maybe_origin_model_title.as_deref(),
|
||||
)),
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(media_domain, server_environment, &public_bucket_path),
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
public_bucket_path: public_bucket_path.get_full_object_path_str().to_string(),
|
||||
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_environment),
|
||||
cover_image: into_api_cover_image(MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
)),
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: record.is_user_upload,
|
||||
is_intermediate_system_file: record.is_intermediate_system_file,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_style_name: record
|
||||
.maybe_prompt_args
|
||||
.as_ref()
|
||||
.and_then(|args| args.style_name.as_ref())
|
||||
.and_then(|style| style.to_style_name()),
|
||||
maybe_duration_millis: record.maybe_duration_millis,
|
||||
stats: SimpleEntityStats {
|
||||
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
|
||||
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
|
||||
},
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn into_api_origin(origin: MediaFileOriginDetails) -> MediaFileOriginDetailsApi {
|
||||
MediaFileOriginDetailsApi {
|
||||
origin_category: origin.origin_category,
|
||||
product_category: origin.product_category,
|
||||
maybe_model: origin.maybe_model.map(|model| match model {
|
||||
MediaFileModelDetails::SystemModel { model_type } => MediaFileModelDetailsApi::SystemModel { model_type },
|
||||
MediaFileModelDetails::ModelWeight { model_type, token, title } => {
|
||||
MediaFileModelDetailsApi::ModelWeight { model_type, token, title }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_api_cover_image(cover: MediaFileCoverImageDetails) -> LegacyMediaFileCoverImageDetails {
|
||||
LegacyMediaFileCoverImageDetails {
|
||||
maybe_cover_image_public_bucket_path: cover.maybe_cover_image_public_bucket_path,
|
||||
maybe_cover_image_public_bucket_url: cover.maybe_cover_image_public_bucket_url,
|
||||
maybe_links: cover.maybe_links.map(|links| CoverImageLinks {
|
||||
cdn_url: links.cdn_url,
|
||||
thumbnail_template: links.thumbnail_template,
|
||||
}),
|
||||
default_cover: MediaFileDefaultCover {
|
||||
image_index: cover.default_cover.image_index,
|
||||
color_index: cover.default_cover.color_index,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn origin_details_preserve_system_and_user_models() {
|
||||
for (model_type, model_token, model_title) in [
|
||||
(None, None, None),
|
||||
(Some(MediaFileOriginModelType::Tacotron2), None, None),
|
||||
(Some(MediaFileOriginModelType::Tacotron2), Some("weight_fixture"), Some("Fixture model")),
|
||||
] {
|
||||
let origin = MediaFileOriginDetails::from_db_fields_str(
|
||||
MediaFileOriginCategory::Upload,
|
||||
MediaFileOriginProductCategory::Unknown,
|
||||
model_type,
|
||||
model_token,
|
||||
model_title,
|
||||
);
|
||||
let original = serde_json::to_value(&origin).unwrap();
|
||||
let converted = serde_json::to_value(into_api_origin(origin)).unwrap();
|
||||
assert_eq!(converted, original);
|
||||
let decoded: MediaFileOriginDetailsApi = serde_json::from_value(converted).unwrap();
|
||||
assert_eq!(serde_json::to_value(decoded).unwrap(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_details_preserve_legacy_fields_and_cdn_links() {
|
||||
for maybe_hash in [None, Some("fixture_cover_hash")] {
|
||||
let cover = MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&MediaFileToken::new_from_str("m_fixture"),
|
||||
MediaDomain::FakeYou,
|
||||
ServerEnvironment::Production,
|
||||
maybe_hash,
|
||||
Some("image_"),
|
||||
Some(".png"),
|
||||
);
|
||||
let original = serde_json::to_value(&cover).unwrap();
|
||||
let converted = serde_json::to_value(into_api_cover_image(cover)).unwrap();
|
||||
assert_eq!(converted, original);
|
||||
let decoded: LegacyMediaFileCoverImageDetails = serde_json::from_value(converted).unwrap();
|
||||
assert_eq!(serde_json::to_value(decoded).unwrap(), original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -548,7 +548,7 @@ fn fixture_video_bytes(duration_millis: u64) -> Vec<u8> {
|
||||
/// the stub Kinovi server. Everything else is inert dummy configuration:
|
||||
/// Redis/Elasticsearch/Stripe/bucket clients are constructed lazily and are
|
||||
/// never called on the video generate path.
|
||||
fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
|
||||
pub(crate) fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
|
||||
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
|
||||
.expect("session cookie manager");
|
||||
let avt_cookie_manager =
|
||||
|
||||
+5
@@ -17,6 +17,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler;
|
||||
use crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler;
|
||||
@@ -115,6 +116,10 @@ pub fn add_media_file_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::get().to(list_media_files_for_user_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/list_v2/user/{username}")
|
||||
.route(web::get().to(list_media_files_for_user_v2_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/mesh/list")
|
||||
.route(web::get().to(list_session_mesh_media_files_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
|
||||
@@ -28,4 +28,6 @@ mod wallet_routes;
|
||||
mod web_referrals_routes;
|
||||
mod webhook_routes;
|
||||
mod weights_routes;
|
||||
pub (super) mod add_application_routes;
|
||||
pub (super) mod add_application_routes;
|
||||
#[cfg(test)]
|
||||
mod user_media_endpoint_tests;
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
//! Real routes, signed sessions, and migrated local database fixtures.
|
||||
//! The fixed-socket isolated harness cannot use production database URLs.
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_http::Request;
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{Service, ServiceResponse};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App};
|
||||
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::ListMediaFilesForUserV2SuccessResponse;
|
||||
use mysql_testing::fixtures::media_files::create_test_video_media_file;
|
||||
use mysql_testing::fixtures::users::create_test_user;
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::media_files_routes::add_media_file_routes;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::build_test_server_state;
|
||||
|
||||
const LEGACY_ITEM_FIELDS: &[&str] = &[
|
||||
"token",
|
||||
"media_class",
|
||||
"media_type",
|
||||
"maybe_engine_category",
|
||||
"maybe_animation_type",
|
||||
"origin",
|
||||
"origin_category",
|
||||
"origin_product_category",
|
||||
"maybe_origin_model_type",
|
||||
"maybe_origin_model_token",
|
||||
"public_bucket_path",
|
||||
"public_bucket_url",
|
||||
"media_links",
|
||||
"maybe_prompt_token",
|
||||
"cover_image",
|
||||
"creator_set_visibility",
|
||||
"is_user_upload",
|
||||
"is_intermediate_system_file",
|
||||
"maybe_title",
|
||||
"maybe_text_transcript",
|
||||
"maybe_style_name",
|
||||
"maybe_duration_millis",
|
||||
"stats",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
];
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn user_media_endpoint_versions_preserve_legacy_contract() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let owner = create_test_user(&db.pool).await.unwrap();
|
||||
for _ in 0..26 {
|
||||
create_test_video_media_file(&db.pool, &owner.user_token, Some(1234))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let state = build_test_server_state(db.pool.clone(), "http://127.0.0.1:1".to_string());
|
||||
let cookie = state
|
||||
.session_cookie_manager
|
||||
.create_cookie(&owner.session_token, &owner.user_token)
|
||||
.unwrap()
|
||||
.into_owned();
|
||||
let app = test::init_service(add_media_file_routes(
|
||||
App::new().app_data(web::Data::new(Arc::new(state))),
|
||||
))
|
||||
.await;
|
||||
let v1 = format!("/v1/media_files/list/user/{}", owner.username);
|
||||
let v2 = format!("/v1/media_files/list_v2/user/{}", owner.username);
|
||||
let session = Some(cookie.value());
|
||||
|
||||
// Default upload exclusion, page size, and original JSON shape stay intact.
|
||||
let (status, excluded) = get(&app, &v1, session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
json!({"success": true, "results": [], "pagination": {"current": 0, "total_page_count": 1}})
|
||||
);
|
||||
let (status, legacy) = get(&app, &format!("{v1}?include_user_uploads=true"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(legacy.as_object().unwrap().len(), 3);
|
||||
assert_eq!(legacy["results"].as_array().unwrap().len(), 25);
|
||||
assert_eq!(legacy["pagination"], json!({"current": 0, "total_page_count": 2}));
|
||||
assert_legacy_item_keys(&legacy["results"][0]);
|
||||
|
||||
// Exact multiples intentionally keep v1's historical extra page.
|
||||
let (_, exact) = get(&app, &format!("{v1}?include_user_uploads=true&page_size=26"), session).await;
|
||||
assert_eq!(exact["results"].as_array().unwrap().len(), 26);
|
||||
assert_eq!(exact["pagination"], json!({"current": 0, "total_page_count": 2}));
|
||||
// V2's size cap must never spill into the legacy endpoint.
|
||||
let (status, large) = get(&app, &format!("{v1}?include_user_uploads=true&page_size=101"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(large["results"].as_array().unwrap().len(), 26);
|
||||
assert_eq!(large["pagination"], json!({"current": 0, "total_page_count": 1}));
|
||||
let (_, last) = get(&app, &format!("{v1}?include_user_uploads=true&page_index=1"), session).await;
|
||||
assert_eq!(last["results"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(last["pagination"], json!({"current": 1, "total_page_count": 2}));
|
||||
// The rejected count-mode option has no effect on the v1 contract.
|
||||
let (_, ignored_option) = get(
|
||||
&app,
|
||||
&format!("{v1}?include_user_uploads=true&include_total_count=false"),
|
||||
session,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ignored_option, legacy);
|
||||
|
||||
let (status, first_v2) = get(&app, &format!("{v2}?include_user_uploads=true"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(first_v2.as_object().unwrap().len(), 3);
|
||||
assert_eq!(first_v2["results"], legacy["results"]);
|
||||
assert_eq!(first_v2["pagination"], json!({"current": 0, "has_more": true}));
|
||||
// A Rust API client can decode and re-encode the full response without
|
||||
// dropping nested or deprecated fields from either endpoint's media items.
|
||||
let typed_v2: ListMediaFilesForUserV2SuccessResponse = serde_json::from_value(first_v2.clone()).unwrap();
|
||||
assert_eq!(typed_v2.results.len(), 25);
|
||||
assert_eq!(serde_json::to_value(typed_v2).unwrap(), first_v2);
|
||||
let (_, last_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=1"), session).await;
|
||||
assert_eq!(last_v2["results"], last["results"]);
|
||||
assert_eq!(last_v2["pagination"], json!({"current": 1, "has_more": false}));
|
||||
let (_, exact_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_size=26"), session).await;
|
||||
assert_eq!(exact_v2["pagination"], json!({"current": 0, "has_more": false}));
|
||||
let (_, empty_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=2"), session).await;
|
||||
assert_eq!(empty_v2["results"], json!([]));
|
||||
assert_eq!(empty_v2["pagination"], json!({"current": 2, "has_more": false}));
|
||||
|
||||
for operation in ["list", "list_v2"] {
|
||||
let path = format!(
|
||||
"/v1/media_files/{operation}/user/{}?include_user_uploads=true",
|
||||
owner.username
|
||||
);
|
||||
let (status, anonymous) = get(&app, &path, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
anonymous["results"],
|
||||
json!([]),
|
||||
"private fixtures must require their owner session"
|
||||
);
|
||||
let (status, missing) = get(
|
||||
&app,
|
||||
&format!("/v1/media_files/{operation}/user/missing_fixture?include_user_uploads=true"),
|
||||
session,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(missing["results"], json!([]));
|
||||
let (status, filtered) = get(&app, &format!("{path}&filter_media_classes=image"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(filtered["results"], json!([]));
|
||||
}
|
||||
for query in [
|
||||
"page_size=0".to_string(),
|
||||
"page_size=101".to_string(),
|
||||
format!("page_size=60&page_index={}", usize::MAX),
|
||||
] {
|
||||
let (status, _) = get(&app, &format!("{v2}?{query}"), session).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
drop(app);
|
||||
db.destroy().await;
|
||||
}
|
||||
|
||||
async fn get<S, B>(app: &S, path: &str, maybe_session: Option<&str>) -> (StatusCode, Value)
|
||||
where
|
||||
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||
B: MessageBody,
|
||||
B::Error: std::fmt::Debug,
|
||||
{
|
||||
let mut request = test::TestRequest::get().uri(path);
|
||||
if let Some(session) = maybe_session {
|
||||
request = request.insert_header(("session", session));
|
||||
}
|
||||
let response = test::call_service(app, request.to_request()).await;
|
||||
let status = response.status();
|
||||
(status, test::read_body_json(response).await)
|
||||
}
|
||||
|
||||
fn assert_legacy_item_keys(item: &Value) {
|
||||
let mut actual = item.as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>();
|
||||
actual.sort_unstable();
|
||||
let mut expected = LEGACY_ITEM_FIELDS.to_vec();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# User media library query
|
||||
|
||||
`GET /v1/media_files/list/user/{username}` runs an exact count before fetching
|
||||
each page. The webapp continues to use it for infinite scroll. MySQL can choose
|
||||
an index-merge plan that intersects the creator index with global indexes on
|
||||
NULL deletion timestamps and the intermediate-file flag. That makes the count
|
||||
scan unrelated users' index entries.
|
||||
|
||||
The queries now force the creator index for counts and the creator/date index
|
||||
for page selection. A limited derived table selects IDs before joining prompt,
|
||||
cover, model, and stats data. Ordering uses `(created_at, id)` to break timestamp
|
||||
ties consistently. Shared predicates preserve author/moderator/public visibility,
|
||||
deleted-file exclusions, uploads, and class/type/engine filters.
|
||||
|
||||
The legacy `GET /v1/media_files/list/user/{username}` contract is unchanged:
|
||||
original query fields and defaults, no new page-size cap, and the original
|
||||
`pagination: { current, total_page_count }` with a numeric total. Its historical
|
||||
`1 + floor(count / page_size)` calculation is intentionally preserved, including
|
||||
exact multiples. It always performs the optimized exact count.
|
||||
|
||||
The separate `GET /v1/media_files/list_v2/user/{username}` endpoint is available
|
||||
for adoption after backend deployment. `list_v2` fetches one extra record
|
||||
and returns `pagination: { current, has_more }`. It never counts the library and
|
||||
has no count-mode flag or total-page field. `list_v2` accepts page sizes from 1 to 100.
|
||||
Both endpoints return the same media-item fields and enforce the same visibility
|
||||
and media filters.
|
||||
|
||||
Skipping the count is the additional speed improvement: even the optimized
|
||||
legacy count must visit all matching rows before returning an exact total. The
|
||||
new endpoint avoids that work on every request, including the first page. For a
|
||||
60-item page it fetches at most 61 result records; it can still scan additional
|
||||
index entries to apply filters or skip an offset. Compatible index-selection
|
||||
and deferred-join improvements remain shared by both endpoints.
|
||||
|
||||
Pagination remains offset based, so very deep pages still scan preceding
|
||||
matching entries. `list_v2` removes the full-library count from infinite-scroll
|
||||
requests, but does not remove that offset cost.
|
||||
|
||||
## Deployment
|
||||
|
||||
The frontend switch is deferred. The webapp continues using
|
||||
`GalleryModalApi.listUserMediaFiles` and the existing
|
||||
`/v1/media_files/list/user/{username}` endpoint with numbered pagination.
|
||||
The `UserMediaFilesV2Api` library binding and its tests are retained, with no live
|
||||
frontend callers.
|
||||
|
||||
Deploy and verify storyteller-web with the new `list_v2` route before a separate
|
||||
frontend change adopts `UserMediaFilesV2Api` and its `has_more` pagination.
|
||||
Both routes remain in API v1; this is an endpoint-local revision. Existing
|
||||
clients and the original bindings remain unchanged.
|
||||
|
||||
No new migration is needed. The existing migration
|
||||
`2026-07-03-074740-0000_alter_media_files_add_user_created_at_index`
|
||||
must already be applied: both page queries require `idx_creator_created_at`.
|
||||
The legacy count query uses the longstanding `fk_maybe_creator_user_token` index.
|
||||
|
||||
Endpoint logs distinguish legacy/list_v2 and report `page_ms`; the legacy route
|
||||
also reports `count_ms`.
|
||||
Use those timings to verify the actual production improvement after deployment.
|
||||
|
||||
## Local validation
|
||||
|
||||
Database tests use `IsolatedTestDatabase`: a fixed `/tmp/mysql.sock`, new random
|
||||
schemas, and checked-in migrations. They do not read production connection URLs
|
||||
or modify existing schemas. From the repository root:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library
|
||||
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library -- --ignored --nocapture
|
||||
```
|
||||
|
||||
The large fixture creates 300,000 rows, 60,000 owned by the target account. On
|
||||
local MySQL 8.4.11, the production page query returned 61 rows in 3.4 ms with 91
|
||||
sequential index reads. Its test asserts bounded reads rather than wall-clock
|
||||
latency. The legacy user-scoped count took 38.5 ms.
|
||||
|
||||
A separate local EXPLAIN ANALYZE comparison with that distribution measured the
|
||||
old count at 166 ms and the forced creator-index count at 41.5 ms. The old plan
|
||||
scanned 300,000 entries in each of three global indexes. These are synthetic
|
||||
local measurements, not production response-time promises.
|
||||
|
||||
Functional fixtures cover public/private/hidden visibility, other owners,
|
||||
deleted and intermediate files, all library media classes including legacy 3D,
|
||||
upload/type/engine filters, same-timestamp pagination, joined metadata, unknown
|
||||
usernames, and caller-owned transactions.
|
||||
|
||||
The real Actix route test uses signed sessions and disposable MySQL fixtures to
|
||||
lock down the original request defaults, response keys/types, exact-multiple page counts,
|
||||
and page sizes above 100. It also checks `list_v2` first/last/empty pages, validation,
|
||||
media-item parity, filters, and private-file access:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test -p storyteller-web --bin storyteller-web user_media_endpoint_versions
|
||||
```
|
||||
|
||||
Frontend HTTP-binding tests:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
node_modules/.bin/vitest run --config libs/api/vite.config.ts src/lib/MediaFilesApi.spec.ts
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ArtCraft Desktop — Open Source AI for Creators</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="ArtCraft Desktop is the best Open Source AI tool for creators. Generate AI images and video with real control, right on your desktop."
|
||||
/>
|
||||
<link rel="canonical" href="https://desktop.getartcraft.com/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://desktop.getartcraft.com/" />
|
||||
<meta property="og:site_name" content="ArtCraft" />
|
||||
<meta property="og:title" content="ArtCraft Desktop" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="ArtCraft Desktop is the best Open Source AI tool for creators."
|
||||
/>
|
||||
<meta property="og:image" content="https://desktop.getartcraft.com/images/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f1ee" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b0b0c" />
|
||||
<link rel="icon" href="/artcraft-icon.svg" type="image/svg+xml" />
|
||||
|
||||
<!-- Same type system as getartcraft.com: Archivo (with its width axis,
|
||||
for the stretched wordmark), Instrument Serif italic accents, Inter
|
||||
body, Geist Mono HUD labels. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Geist+Mono:wght@400..600&family=Instrument+Serif:ital@0;1&family=Inter:wght@400..700&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="site-nav">
|
||||
<a class="nav-logo" href="https://getartcraft.com/" aria-label="ArtCraft home">
|
||||
<svg viewBox="0 0 116.34 97.5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link hud-label" href="https://getartcraft.com/download"><span>Download</span></a>
|
||||
<a class="nav-link hud-label" href="https://github.com/storytold/artcraft"><span>GitHub</span></a>
|
||||
<a class="nav-cta hud-label" href="https://getartcraft.com/">Go to ArtCraft</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="shell">
|
||||
<div class="rail">
|
||||
<span class="tick tick-half" style="top: -5px; left: -6px" aria-hidden="true"></span>
|
||||
<span class="tick tick-half" style="top: -5px; right: -6px" aria-hidden="true"></span>
|
||||
|
||||
<div class="eyebrow">
|
||||
<p class="hud-label muted"><span class="faint">01</span><span class="faint slash" aria-hidden="true">/</span>Desktop</p>
|
||||
<p class="hud-label faint hide-sm">macOS · Windows · Open source</p>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<!-- The wordmark: the brand mark stands in for the leading A,
|
||||
set in Archivo at the poster width like the main site. -->
|
||||
<div class="wordmark-wrap">
|
||||
<div class="wordmark" role="img" aria-label="ArtCraft">
|
||||
<svg class="wordmark-logo" viewBox="0 0 116.34 97.5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z" />
|
||||
</svg><span aria-hidden="true">RTCRAFT</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hud-label accent-ink wordmark-sub">Desktop</p>
|
||||
|
||||
<h1 class="headline">
|
||||
ArtCraft Desktop is the best <em>Open Source</em> AI tool for
|
||||
creators
|
||||
</h1>
|
||||
|
||||
<div class="ctas">
|
||||
<a class="btn btn-primary" href="https://getartcraft.com/download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 17V3" /><path d="m6 11 6 6 6-6" /><path d="M19 21H5" />
|
||||
</svg>
|
||||
Go to downloads
|
||||
</a>
|
||||
<a class="btn btn-secondary" href="https://getartcraft.com/">
|
||||
Go to ArtCraft
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<p class="hud-label faint meta">Free · Open source · macOS & Windows</p>
|
||||
</div>
|
||||
|
||||
<figure class="shot">
|
||||
<div class="shot-bar">
|
||||
<figcaption class="hud-label faint">Viewport — 3D scene editor</figcaption>
|
||||
<p class="hud-label faint">ArtCraft Desktop</p>
|
||||
</div>
|
||||
<div class="shot-frame">
|
||||
<img
|
||||
src="/images/artcraft-desktop-screenshot.jpg"
|
||||
alt="Screenshot of the ArtCraft Desktop app showing the 3D scene editor"
|
||||
width="1920"
|
||||
height="945"
|
||||
/>
|
||||
<span class="tick" style="top: 8px; left: 8px" aria-hidden="true"></span>
|
||||
<span class="tick" style="top: 8px; right: 8px" aria-hidden="true"></span>
|
||||
<span class="tick" style="bottom: 8px; left: 8px" aria-hidden="true"></span>
|
||||
<span class="tick" style="bottom: 8px; right: 8px" aria-hidden="true"></span>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell">
|
||||
<div class="rail">
|
||||
<span class="tick tick-half" style="top: -5px; left: -6px" aria-hidden="true"></span>
|
||||
<span class="tick tick-half" style="top: -5px; right: -6px" aria-hidden="true"></span>
|
||||
|
||||
<div class="eyebrow">
|
||||
<p class="hud-label muted"><span class="faint">02</span><span class="faint slash" aria-hidden="true">/</span>Community</p>
|
||||
<p class="hud-label faint hide-sm">Built in the open</p>
|
||||
</div>
|
||||
|
||||
<ul class="socials">
|
||||
<li>
|
||||
<a class="social invert-block" href="https://github.com/storytold/artcraft" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>
|
||||
<span class="hud-label">GitHub</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://discord.gg/artcraft" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" /></svg>
|
||||
<span class="hud-label">Discord</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://www.youtube.com/@OfficialArtCraftStudios" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /></svg>
|
||||
<span class="hud-label">YouTube</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://www.instagram.com/get_artcraft" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="20" x="2" y="2" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" x2="17.51" y1="6.5" y2="6.5" /></svg>
|
||||
<span class="hud-label">Instagram</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://www.tiktok.com/@artcraft.studios" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" /></svg>
|
||||
<span class="hud-label">TikTok</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="shell">
|
||||
<div class="rail footer-bar">
|
||||
<p class="hud-label faint">© <span id="year">2026</span> ArtCraft</p>
|
||||
<a class="hud-label faint footer-mail" href="mailto:hello@storyteller.ai">hello@storyteller.ai</a>
|
||||
<p class="hud-label faint">Made by artists, for artists</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.getElementById('year').textContent = String(new Date().getFullYear());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
# Netlify config for desktop.getartcraft.com (Nx + Vite static page).
|
||||
#
|
||||
# Netlify settings used to discover this file:
|
||||
# Base directory: frontend
|
||||
# Package directory: apps/artcraft-desktop-website (relative to the base directory)
|
||||
# Manage the build command and publish directory here; leave those UI fields empty.
|
||||
# Paths below are relative to the frontend workspace.
|
||||
|
||||
[build]
|
||||
command = "bash ./apps/artcraft-desktop-website/script/netlify_build.sh"
|
||||
publish = "apps/artcraft-desktop-website/dist/"
|
||||
|
||||
# Vite emits content-hashed files under dist/assets — safe to cache forever.
|
||||
[[headers]]
|
||||
for = "/assets/*"
|
||||
|
||||
[headers.values]
|
||||
"Cache-Control" = "public, max-age=31536000, immutable"
|
||||
|
||||
# Single-page site: unknown paths land on the page instead of a 404.
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@frontend/artcraft-desktop-website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #4e7bfb;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 483 B |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Runs from the Netlify base directory (= the Nx workspace root, `frontend/`).
|
||||
|
||||
echo "Building artcraft-desktop-website"
|
||||
npx nx build @frontend/artcraft-desktop-website
|
||||
|
||||
echo "Final build files:"
|
||||
find apps/artcraft-desktop-website/dist/
|
||||
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* desktop.getartcraft.com — ported from the getartcraft.com design tokens
|
||||
* (apps/artcraft-website-next/src/app/globals.css). Same language: hairline
|
||||
* rules, hard edges, mono HUD labels, a single blue accent, light "blueprint"
|
||||
* and dark "viewport" themes following the system preference.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f2f1ee;
|
||||
--bg-raised: #faf9f7;
|
||||
--bg-sunken: #e9e8e4;
|
||||
--ink: #101014;
|
||||
--ink-strong: #000000;
|
||||
--muted: rgba(16, 16, 20, 0.62);
|
||||
--faint: rgba(16, 16, 20, 0.42);
|
||||
--line: rgba(16, 16, 20, 0.16);
|
||||
--line-strong: rgba(16, 16, 20, 0.4);
|
||||
--accent: #2d81ff;
|
||||
--accent-ink: #1659c4;
|
||||
--invert-bg: #101014;
|
||||
--invert-fg: #f2f1ee;
|
||||
|
||||
--font-display: 'Archivo', system-ui, sans-serif;
|
||||
--font-serif: 'Instrument Serif', Georgia, serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--rail: 1280px;
|
||||
--pad: 24px;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #121316;
|
||||
--bg-raised: #101014;
|
||||
--bg-sunken: #060607;
|
||||
--ink: #f2f1ee;
|
||||
--ink-strong: #ffffff;
|
||||
--muted: rgba(242, 241, 238, 0.6);
|
||||
--faint: rgba(242, 241, 238, 0.38);
|
||||
--line: rgba(242, 241, 238, 0.15);
|
||||
--line-strong: rgba(242, 241, 238, 0.4);
|
||||
--accent-ink: #74aaff;
|
||||
--invert-bg: #ffffff;
|
||||
--invert-fg: #0b0b0c;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--pad: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
figure,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Micro vocabulary ---------- */
|
||||
|
||||
.hud-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.faint {
|
||||
color: var(--faint);
|
||||
}
|
||||
.accent-ink {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.slash {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Crosshair tick straddling a grid intersection. */
|
||||
.tick {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.tick::before,
|
||||
.tick::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
.tick::before {
|
||||
left: 5px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
.tick::after {
|
||||
top: 5px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
/* Half tick for marks sitting on a top edge. */
|
||||
.tick-half::before {
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
/* Signature interaction: blocks invert on hover. */
|
||||
.invert-block {
|
||||
transition:
|
||||
background-color 120ms linear,
|
||||
color 120ms linear;
|
||||
}
|
||||
.invert-block:hover {
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
|
||||
/* ---------- Nav ---------- */
|
||||
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 95%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-right: 1px solid var(--line);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.nav-logo:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.nav-logo svg {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nav-link span {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-link:hover span {
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.nav-link {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
padding: 0 20px;
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
.nav-cta:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ---------- Rails ---------- */
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
max-width: var(--rail);
|
||||
margin: 0 auto;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px var(--pad);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.hide-sm {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.hide-sm {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
|
||||
.hero {
|
||||
padding: 56px var(--pad) 64px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.hero {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Poster wordmark: Archivo at the poster width (wdth 125) with the logo-A
|
||||
standing in for the first letter — the getartcraft.com hero setting.
|
||||
The word's advance run measures ~7.0em at this cut, so sizing the font
|
||||
from the container width justifies it flush across the rail. */
|
||||
.wordmark-wrap {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: var(--ink-strong);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 750;
|
||||
font-stretch: 125%;
|
||||
font-size: calc(100cqw / 7.05);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
animation: fade-up 0.7s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
.wordmark-logo {
|
||||
flex: none;
|
||||
width: calc(0.7em * 116.34 / 97.5);
|
||||
height: 0.7em;
|
||||
margin-right: 0.05em;
|
||||
align-self: baseline;
|
||||
transform: translateY(0.006em);
|
||||
}
|
||||
|
||||
.wordmark-sub {
|
||||
margin-top: 16px;
|
||||
animation: fade-up 0.7s 0.08s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.headline {
|
||||
max-width: 48rem;
|
||||
margin-top: 40px;
|
||||
color: var(--ink-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.25rem;
|
||||
font-weight: 620;
|
||||
font-stretch: 118%;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.035em;
|
||||
animation: fade-up 0.7s 0.16s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
.headline em {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-stretch: normal;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.headline {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.headline {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
animation: fade-up 0.7s 0.24s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 150ms linear,
|
||||
color 150ms linear,
|
||||
border-color 150ms linear,
|
||||
opacity 150ms linear;
|
||||
}
|
||||
.btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
border-color: transparent;
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
|
||||
/* ---------- Screenshot ---------- */
|
||||
|
||||
.shot {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.shot-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px var(--pad);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.shot-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.shot-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.shot-frame .tick {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---------- Socials ---------- */
|
||||
|
||||
.socials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--line);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.socials {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
/* Odd count on the 2-up grid: the last cell spans the row. */
|
||||
.socials li:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.socials li:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.social {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 24px var(--pad);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.social {
|
||||
padding: 32px 28px;
|
||||
}
|
||||
}
|
||||
.social svg {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.social-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--faint);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.social:hover .social-arrow {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
|
||||
.footer-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px var(--pad);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.footer-bar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.footer-mail:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Motion ---------- */
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Static landing page for desktop.getartcraft.com. No framework: index.html
|
||||
// plus one stylesheet, so Vite is only here for dev serving and hashed
|
||||
// production assets.
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/artcraft-desktop-website',
|
||||
server: {
|
||||
port: 4260,
|
||||
host: 'localhost',
|
||||
},
|
||||
preview: {
|
||||
port: 4360,
|
||||
host: 'localhost',
|
||||
},
|
||||
build: {
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
}));
|
||||
@@ -9,6 +9,7 @@ export * from "./lib/GenerationApi.js";
|
||||
export * from "./lib/EngineApi.js";
|
||||
export * from "./lib/JobsApi.js";
|
||||
export * from "./lib/MediaFilesApi.js";
|
||||
export * from "./lib/UserMediaFilesV2Api.js";
|
||||
export * from "./lib/MediaUploadApi.js";
|
||||
export * from "./lib/MiscApi.js";
|
||||
export * from "./lib/LocalApi.js";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GalleryModalApi } from "./GalleryModalApi";
|
||||
import { UserMediaFilesV2Api } from "./UserMediaFilesV2Api";
|
||||
import { FilterMediaClasses } from "./enums/QueryFilters";
|
||||
|
||||
const { fetchProxy } = vi.hoisted(() => ({ fetchProxy: vi.fn() }));
|
||||
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: fetchProxy }));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchProxy.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("versioned user library HTTP contracts", () => {
|
||||
it("keeps existing clients on v1 with numeric numbered pagination", async () => {
|
||||
fetchProxy.mockResolvedValue(new Response(JSON.stringify({
|
||||
success: true, results: [], pagination: { current: 2, total_page_count: 3 },
|
||||
})));
|
||||
const response = await new GalleryModalApi().listUserMediaFiles({ username: "fixture_owner", page_index: 2 });
|
||||
const url = new URL(fetchProxy.mock.calls[0][0]);
|
||||
expect(url.pathname).toBe("/v1/media_files/list/user/fixture_owner");
|
||||
expect(url.searchParams.has("include_total_count")).toBe(false);
|
||||
expect(response.pagination).toEqual({ current: 2, total_page_count: 3 });
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])("requests count-free pages from list_v2 (has_more: %s)", async (hasMore) => {
|
||||
fetchProxy.mockResolvedValue(new Response(JSON.stringify({
|
||||
success: true, results: [], pagination: { current: 1, has_more: hasMore },
|
||||
})));
|
||||
const response = await new UserMediaFilesV2Api().ListUserMediaFiles({
|
||||
username: "fixture_owner", include_user_uploads: true,
|
||||
filter_media_classes: [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO],
|
||||
page_size: 60, page_index: 1,
|
||||
});
|
||||
const url = new URL(fetchProxy.mock.calls[0][0]);
|
||||
expect(url.pathname).toBe("/v1/media_files/list_v2/user/fixture_owner");
|
||||
expect(url.searchParams.has("include_total_count")).toBe(false);
|
||||
expect(url.searchParams.get("filter_media_classes")).toBe("image,video");
|
||||
expect(url.searchParams.get("page_size")).toBe("60");
|
||||
expect(url.searchParams.get("page_index")).toBe("1");
|
||||
expect(response.pagination).toEqual({ current: 1, has_more: hasMore });
|
||||
expect(fetchProxy.mock.calls[0][1].credentials).toBe("include");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ApiManager, type ApiResponse } from "./ApiManager.js";
|
||||
import type { MediaFile } from "./models/MediaFile.js";
|
||||
import type { FilterEngineCategories, FilterMediaClasses, FilterMediaType } from "./enums/QueryFilters.js";
|
||||
|
||||
export interface ListUserMediaFilesV2Query {
|
||||
username: string;
|
||||
sort_ascending?: boolean;
|
||||
page_size?: number;
|
||||
page_index?: number;
|
||||
filter_media_classes?: FilterMediaClasses[];
|
||||
filter_media_type?: FilterMediaType[];
|
||||
filter_engine_categories?: FilterEngineCategories[];
|
||||
include_user_uploads?: boolean;
|
||||
}
|
||||
|
||||
export interface UserMediaFilesV2Pagination {
|
||||
current: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Faster /v1/media_files/list_v2 pages: fetches one extra row for has_more,
|
||||
* avoiding the full-library count required by the original list endpoint.
|
||||
*/
|
||||
export class UserMediaFilesV2Api extends ApiManager {
|
||||
public async ListUserMediaFiles(
|
||||
query: ListUserMediaFilesV2Query,
|
||||
): Promise<ApiResponse<MediaFile[], UserMediaFilesV2Pagination>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list_v2/user/${encodeURIComponent(query.username)}`;
|
||||
return this.get<{
|
||||
success: boolean;
|
||||
results: MediaFile[];
|
||||
pagination: UserMediaFilesV2Pagination;
|
||||
}>({
|
||||
endpoint,
|
||||
query: {
|
||||
sort_ascending: query.sort_ascending,
|
||||
page_size: query.page_size,
|
||||
page_index: query.page_index,
|
||||
include_user_uploads: query.include_user_uploads,
|
||||
filter_media_classes: query.filter_media_classes?.join(","),
|
||||
filter_media_type: query.filter_media_type?.join(","),
|
||||
filter_engine_categories: query.filter_engine_categories?.join(","),
|
||||
},
|
||||
})
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.results ?? [],
|
||||
pagination: response.pagination,
|
||||
}))
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user