Compare commits

..
Author SHA1 Message Date
Brandon Thomas 293a41c7d4 let's actually not call the new endpoint prematurely 2026-09-27 17:35:00 -04:00
Brandon Thomas 55ab800542 more performant list user files endpoint 2026-09-27 15:02:19 -04:00
Brandon Thomas 2c93b4cf42 Merge pull request #36 from storytold/bt-opengraph-metadata
add opengraph metadata (social media tags)
2026-09-26 16:32:39 -07:00
Brandon Thomas 9169e4d662 add opengraph metadata (social media tags) 2026-09-26 16:30:57 -07:00
Brandon Thomas 4c7af51851 Merge pull request #35 from storytold/bt-media-previews
fix media previews in new design
2026-09-26 10:30:44 -07:00
Brandon Thomas eadfb4f821 Merge origin/main into bt-media-previews
Combine the media share implementations, preserving interactive players, social cards, model/provider labels, reference thumbnails, and legacy redirects. Verify with 17 isolated Chrome integration tests, TypeScript checks, and the production build.
2026-09-26 09:58:16 -07:00
Brandon Thomas 0c163d6c19 routes 2026-09-26 09:33:04 -07:00
Brandon Thomas 26484685ee media previews on website 2026-09-26 09:25:40 -07:00
BFlat 5bceb33801 Website: model/provider names and icons, video reference thumbs on share page (#34)
Ports the model-list display names and brand marks, shows the Created
row as date + time like the webapp lightbox, and uses the still-frame
preview for video references.
2026-09-26 21:49:03 +07:00
BFlat c6e2c4296a Website: port media share page (/media/[token]) to artcraft-website-next (#33)
* Website: port the media share page to artcraft-website-next

Adds the /media/[token] route with server-side OG metadata, the media
viewer with prompt/reference/info details, and legacy ?media= redirects.

* no ruler on media share page
2026-09-26 21:36:04 +07:00
Brandon Thomas 8551d58def fix download version again 2026-09-26 04:32:59 -07:00
Brandon Thomas 807180bf22 Merge pull request #32 from storytold/bt-new-website-downloads
new website downloads
2026-09-26 04:24:45 -07:00
Brandon Thomas f48d2beea7 new website downloads 2026-09-26 04:24:15 -07:00
Brandon Thomas dc72bbd5e6 Merge pull request #31 from storytold/bt-sso-otp
desktop app login bridge
2026-09-26 02:43:00 -07:00
53 changed files with 3640 additions and 588 deletions

No files matched your search

+48 -3
View File
@@ -32,12 +32,45 @@ artcraft-services/
│ ├── plugins/ # Shared billing and service components
│ └── web/storyteller_web/ # Main Actix Web HTTP API
├── frontend/
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website/ # Product website at getartcraft.com
│ └── libs/ # Shared React components and TypeScript libraries
│ ├── 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)
│ └── libs/ # Shared React components and TypeScript libraries
└── Cargo.toml # Rust workspace
```
## Updating Desktop Download Links
Desktop release links are configured independently in **three places**. When
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
`MAC_VERSION` in all three unless the request explicitly targets one platform or
app:
1. **Current public website (Next.js):**
[frontend/apps/artcraft-website-next/src/lib/download-links.ts](frontend/apps/artcraft-website-next/src/lib/download-links.ts).
This controls the installer links and visible version at
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
(newest first), then update both version selectors.
2. **Legacy website (Vite):**
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
Updating only this file does **not** update the current public website.
3. **Browser webapp:**
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
Update both version constants; these links are used by the welcome and
checkout-success pages at `app.getartcraft.com`.
Keep previous releases for rollback. The Next.js download page reads its visible
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
not hardcode a separate version in the page component.
Check that all three configs produce the requested release tag and installer
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
require deployment of the affected apps before the public pages change; do not
report a local update as a deployed update.
## Code Style
- Rust with no minimum supported version
@@ -63,6 +96,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
@@ -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?;
}
@@ -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
}
@@ -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;
@@ -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());
}
}
}
@@ -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()
}
@@ -59,6 +59,7 @@ use crate::http_server::endpoints::media_files::get::get_media_file_handler::*;
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::*;
use crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::*;
@@ -519,6 +520,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,
@@ -1182,6 +1184,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListMediaFilesForUserPathInfo,
ListMediaFilesForUserQueryParams,
ListMediaFilesForUserSuccessResponse,
ListMediaFilesForUserV2PathInfo,
ListMediaFilesForUserV2QueryParams,
ListMediaFilesForUserV2SuccessResponse,
UserMediaFileV2Pagination,
ListMediaFilesQueryParams,
ListMediaFilesSuccessResponse,
ListPinnedMediaFilesSuccessResponse,
@@ -3,7 +3,6 @@ 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;
@@ -15,14 +14,17 @@ 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 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 tokens::tokens::media_files::MediaFileToken;
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;
@@ -30,7 +32,6 @@ use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_m
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;
@@ -238,117 +239,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,
}
}))
}
@@ -0,0 +1,197 @@
use std::sync::Arc;
use std::time::Instant;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
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 utoipa::{IntoParams, ToSchema};
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
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;
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFilesForUserV2PathInfo {
username: String,
}
#[derive(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, ToSchema)]
pub struct ListMediaFilesForUserV2SuccessResponse {
pub success: bool,
pub results: Vec<MediaFileForUserListItem>,
pub pagination: UserMediaFileV2Pagination,
}
#[derive(Serialize, ToSchema)]
pub struct UserMediaFileV2Pagination {
pub current: usize,
pub has_more: bool,
}
/// 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;
@@ -0,0 +1,108 @@
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 super::list_media_files_for_user_handler::MediaFileForUserListItem;
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::MediaFileOriginDetails;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
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: 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: 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<_>>()
}
@@ -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 =
@@ -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;
@@ -0,0 +1,177 @@
//! 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 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}));
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);
}
+101
View File
@@ -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
```
@@ -2,13 +2,15 @@
//const WINDOWS_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
//const WINDOWS_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
//const WINDOWS_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
//const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
const WINDOWS_VERSION = "0.41.0";
//const MAC_VERSION = "0.7.0"; // 2026-02-23 Seedance
//const MAC_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
//const MAC_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
//const MAC_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
//const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
const MAC_VERSION = "0.41.0";
export const DOWNLOAD_LINKS = {
WINDOWS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${WINDOWS_VERSION}/ArtCraft_${WINDOWS_VERSION}_x64-setup.exe`,
@@ -2,3 +2,5 @@ node_modules/
.next/
next-env.d.ts
*.tsbuildinfo
playwright-report/
test-results/
@@ -11,3 +11,13 @@ publish = ".next"
[[plugins]]
package = "@netlify/plugin-nextjs"
# Share links. The Next runtime serves /media/:token itself (src/app/media);
# this only maps the legacy query form the Vite site accepted onto the
# canonical route at the edge. Also declared in next.config.ts for local dev.
[[redirects]]
from = "/media"
query = { media = ":token" }
to = "/media/:token"
status = 301
force = true
@@ -2,23 +2,39 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Share previews must work from the raw <head>, even for an unknown crawler
// or curl. Wait for generateMetadata instead of streaming it into the body.
// Media lookups have a five-second timeout; static marketing pages do not
// perform that lookup. https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
htmlLimitedBots: /.*/,
// Pin the project root: the repo has lockfiles both here and in the Nx
// workspace above, and letting Next guess picks the wrong one (breaks
// output file tracing on serverless deploys).
turbopack: { root: __dirname },
outputFileTracingRoot: __dirname,
async redirects() {
// Legacy Stripe return paths from the Vite site; checkout now lives in
// the webapp, which serves the canonical slash forms.
return ["checkout_success", "checkout_cancel", "portal_closed"].map(
(route) => ({
source: `/${route}`,
destination: `https://app.getartcraft.com/checkout/${
route === "checkout_success" ? "success" : "cancel"
}`,
permanent: false,
}),
);
return [
// Legacy Stripe return paths from the Vite site; checkout now lives in
// the webapp, which serves the canonical slash forms.
...["checkout_success", "checkout_cancel", "portal_closed"].map(
(route) => ({
source: `/${route}`,
destination: `https://app.getartcraft.com/checkout/${
route === "checkout_success" ? "success" : "cancel"
}`,
permanent: false,
}),
),
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
// the canonical route is /media/<token>. Mirrored in netlify.toml so
// the edge answers it before the Next runtime.
{
source: "/media",
has: [{ type: "query", key: "media", value: "(?<token>.+)" }],
destination: "/media/:token",
permanent: true,
},
];
},
// The hero wall uses WebGL video textures, which need CORS-clean sources.
// The showcase CDN sends no Access-Control-Allow-Origin header, so these
+84 -13
View File
@@ -12,6 +12,7 @@
"@react-three/drei": "10.7.7",
"@react-three/fiber": "9.6.1",
"@react-three/postprocessing": "3.0.4",
"@sparkjsdev/spark": "0.1.10",
"gsap": "3.15.0",
"lenis": "1.3.23",
"lucide-react": "^1.31.0",
@@ -23,6 +24,7 @@
"zustand": "5.0.14"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@tailwindcss/postcss": "4.3.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -652,9 +654,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -671,9 +670,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -690,9 +686,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -709,9 +702,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -753,6 +743,23 @@
"node": ">= 10"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
@@ -1126,6 +1133,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/webxr": "*",
@@ -1195,6 +1203,15 @@
"three": ">=0.144.0"
}
},
"node_modules/@sparkjsdev/spark": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/@sparkjsdev/spark/-/spark-0.1.10.tgz",
"integrity": "sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1508,6 +1525,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1518,6 +1536,7 @@
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1542,6 +1561,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -1762,6 +1782,21 @@
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glsl-noise": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
@@ -2316,6 +2351,38 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -2350,6 +2417,7 @@
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
"license": "Zlib",
"peer": true,
"peerDependencies": {
"three": ">= 0.168.0 < 0.186.0"
}
@@ -2375,6 +2443,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2384,6 +2453,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -2602,7 +2672,8 @@
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -9,13 +9,15 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:media": "playwright test tests/media.spec.ts tests/media-metadata.spec.ts"
},
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.22",
"@react-three/drei": "10.7.7",
"@react-three/fiber": "9.6.1",
"@react-three/postprocessing": "3.0.4",
"@sparkjsdev/spark": "0.1.10",
"gsap": "3.15.0",
"lenis": "1.3.23",
"lucide-react": "^1.31.0",
@@ -27,6 +29,7 @@
"zustand": "5.0.14"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@tailwindcss/postcss": "4.3.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -0,0 +1,33 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 45_000,
expect: { timeout: 15_000 },
workers: 1,
use: {
// Use installed Google Chrome; no Playwright browser download is needed.
channel: "chrome",
baseURL: "http://localhost:4202",
viewport: { width: 1440, height: 960 },
contextOptions: { reducedMotion: "reduce" },
trace: "retain-on-failure",
launchOptions: { args: ["--enable-unsafe-swiftshader"] },
},
webServer: [
{
command: "node tests/fixtures/api-server.mjs",
url: "http://127.0.0.1:4203/health",
reuseExistingServer: false,
},
{
command: "npm run dev -- --hostname localhost --port 4202",
url: "http://localhost:4202/media/m_fixture_image",
// Server-rendered social metadata also needs fixtures. Never reuse a
// developer's server, which could still be configured for production.
env: { NEXT_PUBLIC_API_HOST: "http://127.0.0.1:4203" },
reuseExistingServer: false,
timeout: 120_000,
},
],
});
@@ -0,0 +1,39 @@
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaTitle } from "@/lib/media";
import { mediaEmbed, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
const HEADERS = { "Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow" };
export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const query = new URL(request.url).searchParams;
if (query.has("format") && query.get("format") !== "json") {
return Response.json({ error: "Only JSON is supported" }, { status: 501, headers: HEADERS });
}
if (query.get("url") !== `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}`) {
return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
}
const media = await fetchPublicMediaFile(token);
const embed = media && mediaEmbed(media);
if (!media || !embed) return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
const playerUrl = `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}/player`;
const scale = Math.min(1, maximum(query.get("maxwidth")) / embed.width, maximum(query.get("maxheight")) / embed.height);
const width = Math.max(1, Math.floor(embed.width * scale));
const height = Math.max(1, Math.floor(embed.height * scale));
return Response.json({
version: "1.0",
type: embed.kind === "video" ? "video" : "rich",
provider_name: "ArtCraft",
provider_url: MEDIA_SHARE_ORIGIN,
title: mediaTitle(media),
author_name: media.maybe_creator_user?.display_name || media.maybe_creator_user?.username,
width,
height,
html: `<iframe src="${playerUrl}" width="${width}" height="${height}" title="ArtCraft media player" frameborder="0" allow="fullscreen" allowfullscreen></iframe>`,
}, { headers: HEADERS });
}
function maximum(value: string | null): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Infinity;
}
@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import MediaPage from "@/components/media/media-page";
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
import { mediaTitle } from "@/lib/media";
const FALLBACK_IMAGE = { url: "/images/og-image.png", width: 1200, height: 630, alt: "ArtCraft" };
type Props = { params: Promise<{ token: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { token } = await params;
const url = `/media/${encodeURIComponent(token)}`;
const fallbackTitle = "Shared media";
const fallbackDescription = "View images, video, music, and interactive 3D creations shared with ArtCraft.";
const metadata: Metadata = {
title: fallbackTitle,
description: fallbackDescription,
alternates: { canonical: url },
// A shared link is not a request to index a user's creation.
robots: { index: false, follow: true },
openGraph: {
type: "website", siteName: "ArtCraft", url,
title: `${fallbackTitle} — ArtCraft`, description: fallbackDescription,
images: [FALLBACK_IMAGE],
},
twitter: {
card: "summary_large_image", title: `${fallbackTitle} — ArtCraft`,
description: fallbackDescription, images: [FALLBACK_IMAGE],
},
};
const media = await fetchPublicMediaFile(token);
if (!media) return metadata;
const title = mediaTitle(media);
const image = mediaShareImage(media);
const embed = mediaEmbed(media);
const images = image ? [{ url: image, alt: title }] : [FALLBACK_IMAGE];
const creator = media.maybe_creator_user;
const description = creator
? `Made with ArtCraft by ${creator.display_name || creator.username}.`
: "Made with ArtCraft.";
return {
...metadata,
title,
description,
openGraph: {
type: embed?.kind === "video" ? "video.other" : "website",
...(embed?.kind === "video" && { videos: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
...(embed?.kind === "audio" && { audio: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
siteName: "ArtCraft",
title: `${title} — ArtCraft`,
description,
url,
images,
},
twitter: {
...(embed ? { card: "player" as const, players: [] } : { card: "summary_large_image" as const }),
title: `${title} — ArtCraft`,
description,
images,
},
...(embed && {
alternates: { canonical: url, types: { "application/json+oembed": `${MEDIA_SHARE_ORIGIN}${url}/oembed?url=${encodeURIComponent(`${MEDIA_SHARE_ORIGIN}${url}`)}&format=json` } },
// Next's player descriptor requires a stream URL, while X's optional
// native stream must be MP4. Audio/WebM use the HTML player instead.
other: {
"twitter:player": `${MEDIA_SHARE_ORIGIN}${url}/player`,
"twitter:player:width": embed.width,
"twitter:player:height": embed.height,
...(embed.contentType === "video/mp4" && {
"twitter:player:stream": embed.url,
"twitter:player:stream:content_type": embed.contentType,
}),
},
}),
};
}
export default async function Media({ params }: Props) {
const { token } = await params;
// Resolve in the browser, using the same session credentials as the Vite
// viewer. Never cache one visitor's private media response in server HTML.
return <MediaPage key={token} token={token} />;
}
@@ -0,0 +1,35 @@
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaTitle } from "@/lib/media";
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
const HEADERS = {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "noindex, nofollow",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src https:; media-src https:; base-uri 'none'; form-action 'none'",
};
// A standalone HTML document bypasses the site layout and needs no scripts.
// Platforms that support iframe Player Cards can use native media controls.
export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const media = await fetchPublicMediaFile(token);
const embed = media && mediaEmbed(media);
if (!media || !embed) return new Response("Media unavailable", { status: 404, headers: HEADERS });
const title = escapeHtml(mediaTitle(media));
const poster = escapeHtml(mediaShareImage(media) ?? `${MEDIA_SHARE_ORIGIN}/images/og-image.png`);
const source = `<source src="${escapeHtml(embed.url)}" type="${embed.contentType}">`;
const player = embed.kind === "video"
? `<video controls playsinline preload="metadata" poster="${poster}" aria-label="${title}">${source}</video>`
: `<div class="audio"><img src="${poster}" alt=""><div><p>${title}</p><audio controls preload="metadata" aria-label="${title}">${source}</audio></div></div>`;
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} — ArtCraft</title><style>
:root{color-scheme:light dark}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font:14px system-ui;background:light-dark(#f2f1ee,#121316);color:light-dark(#222,#eee)}
video{display:block;width:100%;height:100%;object-fit:contain;background:#000}.audio{display:flex;align-items:center;gap:16px;padding:16px;height:100%}.audio img{width:80px;height:80px;object-fit:cover}.audio div{min-width:0;flex:1}p{margin:0 0 12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}audio{width:100%}@media(max-width:360px){.audio img{display:none}}
</style></head><body>${player}</body></html>`, { headers: HEADERS });
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
@@ -0,0 +1,46 @@
.range {
appearance: none;
height: 24px;
background: transparent;
cursor: pointer;
}
.range::-webkit-slider-runnable-track {
height: 3px;
background: linear-gradient(to right, var(--accent-ink) var(--progress), var(--line-strong) var(--progress));
}
.range::-moz-range-track {
height: 3px;
background: linear-gradient(to right, var(--accent-ink) var(--progress), var(--line-strong) var(--progress));
}
.range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
margin-top: -4.5px;
border: 2px solid var(--bg-raised);
border-radius: 0;
background: var(--accent-ink);
box-shadow: 0 0 0 1px var(--accent-ink);
}
.range::-moz-range-thumb {
width: 9px;
height: 9px;
border: 2px solid var(--bg-raised);
border-radius: 0;
background: var(--accent-ink);
box-shadow: 0 0 0 1px var(--accent-ink);
}
.range:focus-visible {
outline: 2px solid var(--accent-ink);
outline-offset: 4px;
}
.range:disabled {
cursor: default;
opacity: 0.4;
}
@@ -0,0 +1,138 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { Music2Icon, PauseIcon, PlayIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import styles from "./audio-player.module.css";
import AudioWaveform from "./audio-waveform";
export default function AudioPlayer({ src, title, onError }: {
src: string; title: string; onError: () => void;
}) {
const audio = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [position, setPosition] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [message, setMessage] = useState("");
const audioGraph = useRef<{ context: AudioContext; source: MediaElementAudioSourceNode; analyser: AnalyserNode } | null>(null);
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
useEffect(() => () => {
const graph = audioGraph.current;
graph?.source.disconnect();
graph?.analyser.disconnect();
if (graph) void graph.context.close();
}, []);
async function togglePlayback() {
const element = audio.current;
if (!element) return;
setMessage("");
if (!element.paused) { element.pause(); return; }
// Initialize only on the user's play gesture so browser audio policies are
// honored. Native playback still works when Web Audio isn't available.
if (!audioGraph.current && typeof AudioContext !== "undefined") {
try {
const context = new AudioContext();
const source = context.createMediaElementSource(element);
const node = context.createAnalyser();
node.fftSize = 2048;
source.connect(node);
node.connect(context.destination);
audioGraph.current = { context, source, analyser: node };
setAnalyser(node);
} catch { /* The native player can continue without a waveform. */ }
}
if (audioGraph.current) await audioGraph.current.context.resume().catch(() => {});
try { await element.play(); }
catch { setMessage("Playback could not start. Press play to try again."); }
}
function seek(value: number) {
if (!audio.current || !duration) return;
audio.current.currentTime = value;
setPosition(value);
}
function changeVolume(value: number) {
if (!audio.current) return;
audio.current.volume = value;
audio.current.muted = false;
setVolume(value);
setMuted(false);
}
function toggleMute() {
if (!audio.current) return;
const next = !(muted || volume === 0);
audio.current.muted = next;
if (!next && volume === 0) { audio.current.volume = 1; setVolume(1); }
setMuted(next);
}
const silent = muted || volume === 0;
return (
<div role="group" aria-label="Audio player" className="w-full max-w-xl px-6 py-12 sm:px-10">
<audio ref={audio} src={src} crossOrigin="anonymous" preload="metadata" className="hidden" onError={onError}
onLoadedMetadata={() => setDuration(Number.isFinite(audio.current?.duration) ? audio.current!.duration : 0)}
onDurationChange={() => setDuration(Number.isFinite(audio.current?.duration) ? audio.current!.duration : 0)}
onTimeUpdate={() => setPosition(audio.current?.currentTime ?? 0)}
onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
<div className="border border-line-strong bg-bg-raised">
<div className="flex items-center justify-between border-b border-line px-5 py-3">
<span className="hud-label text-muted">Audio / Original</span>
<span className="hud-label flex items-center gap-2 text-accent-ink">
<span aria-hidden className={`h-1.5 w-1.5 ${playing ? "bg-accent" : "border border-line-strong"}`} />
{playing ? "Playing" : "Ready to play"}
</span>
</div>
<div className="flex flex-col items-center px-6 py-10 sm:py-14">
<div className="relative mb-8 flex h-32 w-32 items-center justify-center border border-line">
<span aria-hidden className="tick -left-[5px] -top-[5px]" />
<span aria-hidden className="tick -bottom-[5px] -right-[5px]" />
<div className="flex h-24 w-24 items-center justify-center border border-line bg-bg-sunken">
<Music2Icon aria-hidden className="h-10 w-10 text-accent-ink" strokeWidth={1} />
</div>
</div>
<h2 className="w-full break-words text-center font-display text-xl leading-snug text-ink-strong">{title}</h2>
<p className="hud-label mt-3 text-faint">Shared with ArtCraft</p>
</div>
<AudioWaveform analyser={analyser} playing={playing} />
<div className="p-5 sm:p-6">
<input type="range" aria-label="Seek audio" aria-valuetext={`${timeLabel(position)} of ${timeLabel(duration)}`}
min={0} max={duration || 1} step={0.01} value={position} disabled={!duration}
onChange={(event) => seek(Number(event.target.value))}
style={{ "--progress": `${duration ? position / duration * 100 : 0}%` } as CSSProperties}
className={`${styles.range} w-full`} />
<div className="hud-label mt-2 flex justify-between text-muted">
<output aria-label="Elapsed time">{timeLabel(position)}</output>
<span aria-label="Track duration">{timeLabel(duration)}</span>
</div>
<div className="mt-5 flex items-center justify-between gap-4">
<button type="button" aria-label={playing ? "Pause audio" : "Play audio"} onClick={togglePlayback}
className="flex h-14 w-14 shrink-0 items-center justify-center bg-invert-bg text-invert-fg transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-ink">
{playing ? <PauseIcon aria-hidden className="h-5 w-5" fill="currentColor" /> : <PlayIcon aria-hidden className="h-5 w-5" fill="currentColor" />}
</button>
<div className="flex items-center gap-3">
<button type="button" aria-label={silent ? "Unmute audio" : "Mute audio"} onClick={toggleMute}
className="flex h-10 w-10 items-center justify-center text-muted hover:text-ink focus-visible:outline-2 focus-visible:outline-accent-ink">
{silent ? <VolumeXIcon aria-hidden className="h-5 w-5" /> : <Volume2Icon aria-hidden className="h-5 w-5" />}
</button>
<input type="range" aria-label="Audio volume" aria-valuetext={`${Math.round((silent ? 0 : volume) * 100)}%`}
min={0} max={1} step={0.01} value={silent ? 0 : volume} onChange={(event) => changeVolume(Number(event.target.value))}
style={{ "--progress": `${(silent ? 0 : volume) * 100}%` } as CSSProperties} className={`${styles.range} w-20 sm:w-24`} />
</div>
</div>
{message && <p role="status" className="mt-4 text-sm text-muted">{message}</p>}
</div>
</div>
</div>
);
}
function timeLabel(seconds: number): string {
const value = Math.max(0, Math.floor(seconds));
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`;
}
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useRef } from "react";
export default function AudioWaveform({ analyser, playing }: { analyser: AnalyserNode | null; playing: boolean }) {
const line = useRef<SVGPathElement>(null);
useEffect(() => {
if (!analyser || !playing) return;
const samples = new Float32Array(analyser.fftSize);
let frame = 0;
// A live oscilloscope: actual audio samples, never decorative random bars.
// Motion-sensitive visitors retain the static baseline and all controls.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
function draw() {
analyser!.getFloatTimeDomainData(samples);
const points = Array.from({ length: 160 }, (_, index) => {
const amplitude = samples[Math.floor(index / 160 * samples.length)];
return `${index ? "L" : "M"}${index / 159 * 640},${36 - Math.max(-1, Math.min(1, amplitude)) * 30}`;
});
line.current?.setAttribute("d", points.join(" "));
frame = requestAnimationFrame(draw);
}
draw();
return () => cancelAnimationFrame(frame);
}, [analyser, playing]);
return (
<div className="relative w-full border-y border-line bg-bg-sunken px-5 py-3" aria-label="Live audio waveform">
<svg viewBox="0 0 640 72" className="h-16 w-full text-accent-ink" preserveAspectRatio="none" aria-hidden>
<path d="M0,36 L640,36" stroke="var(--line)" strokeWidth="1" />
<path ref={line} d="M0,36 L640,36" fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { MEDIA_LABELS, corsMediaUrl, mediaDetailLabel, mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
import { getModelDisplayName, getModelIcon, getProviderDisplayName, getProviderIcon } from "@/lib/model-names";
const COPY_FEEDBACK_MS = 1500;
const SHARE_URL_BASE = "https://getartcraft.com/media/";
export default function MediaDetails({ media, prompt, promptLoading, dimensions }: {
media: SharedMedia; prompt: MediaPrompt | null; promptLoading: boolean; dimensions: MediaDimensions | null;
}) {
const [copied, setCopied] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState("");
const downloadController = useRef<AbortController | null>(null);
useEffect(() => () => downloadController.current?.abort(), []);
useEffect(() => {
if (!copied) return;
const timeout = setTimeout(() => setCopied(null), COPY_FEEDBACK_MS);
return () => clearTimeout(timeout);
}, [copied]);
async function copy(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
setCopied(label);
setMessage("");
} catch {
setMessage("Copy is unavailable in this browser.");
}
}
async function download() {
setDownloading(true);
setMessage("");
const controller = new AbortController();
downloadController.current = controller;
try {
const url = new URL(corsMediaUrl(media.media_links.cdn_url));
url.searchParams.set("dl", "1");
const response = await fetch(url, { credentials: "omit", signal: controller.signal });
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
if (controller.signal.aborted) return;
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${media.token}.${mediaFormat(media) || "bin"}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// Give the browser time to start consuming the download before revoking.
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch {
if (!controller.signal.aborted) setMessage("Download failed. Use Open original to save the file directly.");
} finally {
if (!controller.signal.aborted) setDownloading(false);
}
}
const kind = mediaKind(media);
const created = media.created_at ? new Date(media.created_at) : null;
const details: [string, ReactNode][] = [
["Type", MEDIA_LABELS[kind]],
["Format", mediaFormat(media).toUpperCase()],
["Model", media.maybe_model_weight_info?.title || (prompt?.maybe_model_type && <BrandValue
icon={getModelIcon(prompt.maybe_model_type)} label={getModelDisplayName(prompt.maybe_model_type)} />)],
["Provider", prompt?.maybe_generation_provider && <BrandValue
icon={getProviderIcon(prompt.maybe_generation_provider)} label={getProviderDisplayName(prompt.maybe_generation_provider)} />],
["Aspect ratio", mediaDetailLabel(prompt?.maybe_aspect_ratio)],
["Resolution", mediaDetailLabel(prompt?.maybe_resolution)],
["Duration", media.maybe_duration_millis != null ? `${(media.maybe_duration_millis / 1000).toFixed(1)} sec` : prompt?.maybe_duration_seconds != null ? `${prompt.maybe_duration_seconds} sec` : null],
["Audio", prompt?.maybe_generate_audio == null ? null : prompt.maybe_generate_audio ? "On" : "Off"],
["Size", dimensions ? `${dimensions.width} × ${dimensions.height}` : null],
["Created", created && !Number.isNaN(created.valueOf()) ? <time dateTime={media.created_at} className="flex flex-col items-end gap-0.5 tabular-nums">
<span>{created.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}</span>
<span className="text-xs text-muted">{created.toLocaleTimeString()}</span>
</time> : null],
];
return (
<aside aria-label="Media details" className="min-w-0 border-t border-line bg-bg lg:border-l lg:border-t-0">
<div className="border-b border-line p-6">
<p className="hud-label mb-5 text-faint">Creation details</p>
<dl className="space-y-3 text-sm">
{details.filter((entry) => entry[1]).map(([label, value]) => (
<div key={label} className="flex justify-between gap-4">
<dt className="shrink-0 text-muted">{label}</dt>
<dd className="break-words text-right text-ink">{value}</dd>
</div>
))}
</dl>
</div>
{media.maybe_prompt_token && (
<div className="border-b border-line p-6">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="hud-label text-faint">Prompt</h2>
{prompt?.maybe_positive_prompt && (
<button type="button" className="text-muted hover:text-ink" aria-label={copied === "prompt" ? "Prompt copied" : "Copy prompt"}
onClick={() => copy(prompt.maybe_positive_prompt!, "prompt")}>
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
</button>
)}
</div>
<p className="max-h-72 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">
{promptLoading ? "Loading prompt…" : prompt?.maybe_positive_prompt || "Prompt details aren’t available for this creation."}
</p>
{prompt?.maybe_negative_prompt && (
<details className="mt-4 text-sm text-muted">
<summary className="cursor-pointer">Negative prompt</summary>
<p className="mt-2 whitespace-pre-wrap break-words">{prompt.maybe_negative_prompt}</p>
</details>
)}
{!!prompt?.maybe_context_images?.length && (
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="Reference images">
{prompt.maybe_context_images.map((image, index) => {
const src = mediaThumbnail(image.media_links, 256) ?? safeMediaUrl(image.media_links.cdn_url);
return src ? (
<Link key={`${image.media_token}-${index}`} href={`/media/${encodeURIComponent(image.media_token)}`} className="border border-line">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={corsMediaUrl(src)} alt={image.semantic || `Reference ${index + 1}`} loading="lazy" className="aspect-square w-full object-cover" />
</Link>
) : null;
})}
</div>
)}
</div>
)}
{media.maybe_text_transcript && (
<div className="border-b border-line p-6">
<h2 className="hud-label mb-4 text-faint">Transcript / lyrics</h2>
<p className="max-h-72 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">{media.maybe_text_transcript}</p>
</div>
)}
<div className="flex flex-col gap-3 p-6">
<Button onClick={download} loading={downloading} className="w-full">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />{downloading ? "Downloading" : "Download file"}
</Button>
<Button variant="secondary" className="w-full" onClick={() => copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link")}>
{copied === "link" ? <CheckIcon aria-hidden className="h-4 w-4" /> : <CopyIcon aria-hidden className="h-4 w-4" />}
{copied === "link" ? "Link copied" : "Copy share link"}
</Button>
<a href={media.media_links.cdn_url} target="_blank" rel="noopener noreferrer" className="hud-label flex items-center justify-center gap-2 py-2 text-muted hover:text-ink">
Open original <ExternalLinkIcon aria-hidden className="h-3 w-3" />
</a>
<p role="status" className="text-sm text-muted">{message}</p>
<div className="mt-3 border-t border-line pt-5">
<p className="mb-4 text-sm leading-relaxed text-muted">Make something of your own. Images, films, and worlds — all in ArtCraft.</p>
<Button href="/download" variant="secondary" className="w-full">Get ArtCraft</Button>
</div>
</div>
</aside>
);
}
function BrandValue({ icon, label }: { icon: string | null; label: string }) {
return (
<span className="inline-flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
{icon && <img src={icon} alt="" className="themed-logo h-4 w-4" />}
{label}
</span>
);
}
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowUpRightIcon, LoaderCircleIcon } from "lucide-react";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Accent } from "@/components/page/page-header";
import { Button } from "@/components/ui";
import { getMediaPrompt, getSharedMedia } from "@/lib/media-api";
import { MEDIA_LABELS, mediaKind, mediaTitle, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
import { webappUrl } from "@/lib/links";
import MediaPreview from "./media-preview";
import MediaDetails from "./media-details";
type MediaState =
| { status: "loading" }
| { status: "ready"; media: SharedMedia }
| { status: "error"; code?: number };
export default function MediaPage({ token }: { token: string }) {
const [state, setState] = useState<MediaState>({ status: "loading" });
const [prompt, setPrompt] = useState<MediaPrompt | null>(null);
const [promptLoading, setPromptLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const [dimensions, setDimensions] = useState<MediaDimensions | null>(null);
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading" });
setPrompt(null);
setPromptLoading(false);
setDimensions(null);
async function load() {
const result = await getSharedMedia(token, controller.signal);
if (controller.signal.aborted) return;
if (!result.success) {
setState({ status: "error", code: result.status });
return;
}
setState({ status: "ready", media: result.data });
if (result.data.maybe_prompt_token) {
setPromptLoading(true);
const resultPrompt = await getMediaPrompt(result.data.maybe_prompt_token, controller.signal);
if (controller.signal.aborted) return;
setPrompt(resultPrompt.success ? resultPrompt.data : null);
setPromptLoading(false);
}
}
void load();
return () => controller.abort();
}, [token, attempt]);
const media = state.status === "ready" ? state.media : null;
const kind = media ? mediaKind(media) : null;
const title = media ? mediaTitle(media) : "Shared creation";
return (
<SectionShell className="min-h-[70vh] overflow-x-clip md:overflow-x-visible">
<SectionEyebrow index="01" label="Shared creation" annotation={kind ? MEDIA_LABELS[kind] : "Made with ArtCraft"} />
<header className="flex flex-col items-start gap-6 px-6 py-8 sm:flex-row sm:items-end sm:justify-between md:px-10 md:py-10">
<div className="w-full min-w-0 flex-1 sm:w-auto">
<p className="hud-label mb-3 text-faint">The artist’s viewport</p>
<h1 className="break-words font-display text-3xl font-medium leading-tight tracking-tight text-ink-strong sm:text-4xl">
{media ? title : <>Shared <Accent>creation</Accent>.</>}
</h1>
{media?.maybe_creator_user && (
<p className="mt-3 text-sm text-muted">
By <span className="text-ink">{media.maybe_creator_user.display_name || media.maybe_creator_user.username}</span>
<span className="ml-2 text-faint">@{media.maybe_creator_user.username}</span>
</p>
)}
</div>
<Button href={webappUrl(`/media/${encodeURIComponent(token)}`)} variant="secondary">
Open in ArtCraft <ArrowUpRightIcon aria-hidden className="h-4 w-4" />
</Button>
</header>
{state.status === "loading" && (
<div role="status" className="flex min-h-[50vh] flex-col items-center justify-center gap-4 border-t border-line bg-bg-sunken text-muted">
<LoaderCircleIcon aria-hidden className="h-6 w-6 animate-spin motion-reduce:animate-none" />
<span className="hud-label">Loading media</span>
</div>
)}
{state.status === "error" && (
<div role="alert" className="flex min-h-[45vh] flex-col items-center justify-center gap-5 border-t border-line px-6 py-16 text-center">
<h2 className="font-display text-2xl text-ink-strong">
{state.code === 404 ? "Media not found" : state.code === 401 || state.code === 403 ? "This media is private" : "Unable to load this media"}
</h2>
<p className="max-w-md text-muted">
{state.code === 404 ? "This link may have been removed or is no longer available."
: state.code === 401 || state.code === 403 ? "Open ArtCraft and sign in to an account with access."
: "The media could not be reached. Please try again."}
</p>
<Button variant="secondary" onClick={() => setAttempt((value) => value + 1)}>Try again</Button>
</div>
)}
{media && (
<div className="grid border-t border-line lg:grid-cols-[minmax(0,1fr)_340px]">
<MediaPreview key={media.token} media={media} onDimensions={setDimensions} />
<MediaDetails media={media} prompt={prompt} promptLoading={promptLoading} dimensions={dimensions} />
</div>
)}
<noscript>
<p className="px-6 py-12 text-muted">Enable JavaScript to load this shared media, or open it in ArtCraft using the link above.</p>
</noscript>
</SectionShell>
);
}
@@ -0,0 +1,68 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import { FileIcon, LoaderCircleIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { corsMediaUrl, mediaFormat, mediaKind, mediaPoster, mediaTitle, type MediaDimensions, type SharedMedia } from "@/lib/media";
import AudioPlayer from "./audio-player";
// WebGL and Spark are only loaded when someone opens a 3D creation.
const MediaViewer3D = dynamic(() => import("./media-viewer-3d"), {
ssr: false,
loading: () => <PreviewLoading label="Loading 3D viewer" />,
});
export default function MediaPreview({ media, onDimensions }: {
media: SharedMedia; onDimensions: (dimensions: MediaDimensions) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const kind = mediaKind(media);
const url = corsMediaUrl(media.media_links.cdn_url);
const title = mediaTitle(media);
return (
<div className="relative flex min-h-[360px] min-w-0 items-center justify-center bg-bg-sunken lg:min-h-[620px]" data-media-kind={kind}>
{failed ? (
<div role="alert" className="flex flex-col items-center gap-4 p-10 text-center">
<p className="text-muted">This browser could not display the file.</p>
<Button href={media.media_links.cdn_url} target="_blank" rel="noopener noreferrer" variant="secondary">Open original</Button>
</div>
) : kind === "mesh" || kind === "splat" ? (
<MediaViewer3D url={url} kind={kind} format={mediaFormat(media)} />
) : kind === "image" ? (
<>
{/* User media comes from the API; preserve the original resolution. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={title} onLoad={(event) => {
setLoaded(true);
onDimensions({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
}} onError={() => setFailed(true)}
className="max-h-[80svh] w-full object-contain" />
{!loaded && <PreviewLoading label="Loading image" />}
</>
) : kind === "video" ? (
<video src={url} aria-label={title} controls playsInline preload="metadata" poster={mediaPoster(media)}
onLoadedMetadata={(event) => onDimensions({ width: event.currentTarget.videoWidth, height: event.currentTarget.videoHeight })}
onError={() => setFailed(true)} className="max-h-[80svh] w-full" />
) : kind === "audio" ? (
<AudioPlayer src={url} title={title} onError={() => setFailed(true)} />
) : (
<div className="flex flex-col items-center gap-4 p-10 text-center text-muted">
<FileIcon aria-hidden className="h-10 w-10" strokeWidth={1} />
<p>A browser preview isn’t available for this file. You can download the original or open it in ArtCraft.</p>
</div>
)}
</div>
);
}
function PreviewLoading({ label }: { label: string }) {
return (
<div role="status" className="absolute inset-0 flex items-center justify-center gap-3 bg-bg-sunken/90 text-muted">
<LoaderCircleIcon aria-hidden className="h-5 w-5 animate-spin motion-reduce:animate-none" />
<span className="hud-label">{label}</span>
</div>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { ArrowLeftIcon, ArrowRightIcon, LoaderCircleIcon, MinusIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
import { Button } from "@/components/ui";
import type { SplatMesh } from "@sparkjsdev/spark";
export default function MediaViewer3D({ url, kind, format }: {
url: string; kind: "mesh" | "splat"; format: string;
}) {
const container = useRef<HTMLDivElement>(null);
const resetView = useRef(() => {});
const frameView = useRef(() => {});
const zoomView = useRef((_factor: number) => {});
const rotateView = useRef((_angle: number) => {});
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
useEffect(() => {
const element = container.current;
if (!element) return;
let cancelled = false;
let model: THREE.Object3D | null = null;
let splat: SplatMesh | null = null;
let splatDisposed = false;
let mixer: THREE.AnimationMixer | null = null;
let renderer: THREE.WebGLRenderer;
setStatus("loading");
try {
renderer = new THREE.WebGLRenderer({ antialias: kind === "mesh", alpha: true });
} catch {
setError("Interactive 3D needs WebGL. You can still download the file or open it in ArtCraft.");
setStatus("error");
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0);
renderer.domElement.setAttribute("aria-label", kind === "splat" ? "Interactive 3D splat" : "Interactive 3D mesh");
renderer.domElement.tabIndex = 0;
element.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 10000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.listenToKeyEvents(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x6b7280, 2));
const key = new THREE.DirectionalLight(0xffffff, 3);
key.position.set(4, 8, 6);
scene.add(key);
const fill = new THREE.DirectionalLight(0xd6e7ff, 1.5);
fill.position.set(-4, 2, -3);
scene.add(fill);
scene.add(new THREE.AmbientLight(0xffffff, 1.5));
function disposeSplat() {
if (!splat || splatDisposed) return;
splatDisposed = true;
splat.dispose();
}
function resize() {
const width = Math.max(element!.clientWidth, 1);
const height = Math.max(element!.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
const observer = new ResizeObserver(resize);
observer.observe(element);
resize();
function failed(message: string) {
if (cancelled) return;
renderer.setAnimationLoop(null);
setError(message);
setStatus("error");
}
function lost(event: Event) {
event.preventDefault();
failed("The 3D viewer lost its graphics connection. Reload this page to try again.");
}
renderer.domElement.addEventListener("webglcontextlost", lost);
async function load() {
try {
let bounds: THREE.Box3;
if (kind === "splat") {
const { SplatMesh, SplatFileType } = await import("@sparkjsdev/spark");
if (cancelled) return;
const fileType = Object.values(SplatFileType).find((value) => value === format);
const loaded = new SplatMesh({ url, fileType });
// Keep ownership across await so failed decodes also release resources.
splat = loaded;
await loaded.initialized;
if (cancelled) { disposeSplat(); return; }
// Match the coordinate convention used by the Vite viewer.
loaded.rotation.z = Math.PI;
loaded.updateMatrixWorld(true);
bounds = loaded.getBoundingBox().applyMatrix4(loaded.matrixWorld);
model = loaded;
} else {
const loaded = await loadMesh(url, format);
if (cancelled) { disposeModel(loaded.model); return; }
model = loaded.model;
bounds = new THREE.Box3().setFromObject(model);
if (bounds.isEmpty()) {
// Skeleton-only GLBs still need a visible rig and finite framing.
model.updateMatrixWorld(true);
model.traverse((node) => bounds.expandByPoint(node.getWorldPosition(new THREE.Vector3())));
scene.add(new THREE.SkeletonHelper(model));
}
if (loaded.animations.length) {
mixer = new THREE.AnimationMixer(model);
mixer.clipAction(loaded.animations[0]).play();
}
}
scene.add(model);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const radius = Math.max(size.length() / 2, 0.5);
frameView.current = () => {
const halfFov = THREE.MathUtils.degToRad(camera.fov / 2);
const distance = radius / Math.sin(Math.atan(Math.tan(halfFov) * Math.min(camera.aspect, 1))) * 1.15;
camera.near = Math.max(radius / 1000, 0.001);
camera.far = Math.max(radius * 100, 1000);
camera.position.copy(center).add(new THREE.Vector3(1, 0.5, 1).normalize().multiplyScalar(distance));
camera.updateProjectionMatrix();
controls.target.copy(center);
controls.maxDistance = radius * 50;
controls.update();
};
resetView.current = () => {
frameView.current();
if (kind === "splat") {
// World captures are meant to be viewed from their capture origin.
// Framing their outer bounds shows the back of the environment.
// Preserve the Vite viewer's initial orbit near that origin; the
// separate Frame all action still works for object-sized splats.
camera.near = 0.01;
camera.position.set(0.85, 0, 0.85);
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
}
};
resetView.current();
zoomView.current = (factor) => {
const offset = camera.position.clone().sub(controls.target);
offset.multiplyScalar(factor).clampLength(camera.near * 2, controls.maxDistance);
camera.position.copy(controls.target).add(offset);
controls.update();
};
rotateView.current = (angle) => {
const offset = camera.position.clone().sub(controls.target).applyAxisAngle(camera.up, angle);
camera.position.copy(controls.target).add(offset);
controls.update();
};
let previous = performance.now();
renderer.setAnimationLoop(() => {
try {
const now = performance.now();
mixer?.update(Math.min((now - previous) / 1000, 0.1));
previous = now;
controls.update();
renderer.render(scene, camera);
} catch {
failed("This file could not be rendered by your browser. The original is available to download.");
}
});
setStatus("ready");
} catch {
disposeSplat();
failed("This 3D file could not be loaded. Download the original or open it in ArtCraft.");
}
}
void load();
return () => {
cancelled = true;
observer.disconnect();
renderer.setAnimationLoop(null);
renderer.domElement.removeEventListener("webglcontextlost", lost);
controls.dispose();
mixer?.stopAllAction();
if (model && mixer) mixer.uncacheRoot(model);
// An in-flight splat is disposed after its decode finishes above.
if (splat?.isInitialized) disposeSplat();
disposeModel(scene);
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
};
}, [url, kind, format]);
return (
<div className="relative h-[60svh] min-h-[360px] w-full lg:h-[700px]" data-lenis-prevent>
<div ref={container} className="absolute inset-0 touch-none" />
{status === "loading" && (
<div role="status" className="absolute inset-0 flex items-center justify-center gap-3 bg-bg-sunken/90 text-muted">
<LoaderCircleIcon aria-hidden className="h-5 w-5 animate-spin motion-reduce:animate-none" />
<span className="hud-label">Loading 3D {kind === "splat" ? "splat" : "mesh"}</span>
</div>
)}
{status === "error" && <p role="alert" className="absolute inset-0 flex items-center justify-center bg-bg-sunken p-10 text-center text-muted">{error}</p>}
{status === "ready" && (
<div className="absolute inset-x-0 bottom-0 flex flex-wrap items-center justify-between gap-3 border-t border-line bg-bg/90 p-4 backdrop-blur-md">
<p className="hud-label text-muted">Drag to orbit · Scroll / pinch to zoom · Right-drag to pan</p>
<div className="flex flex-wrap gap-2" role="group" aria-label="3D view controls">
<Button variant="secondary" size="sm" aria-label="Rotate left" onClick={() => rotateView.current(-Math.PI / 8)}><ArrowLeftIcon aria-hidden className="h-3 w-3" /></Button>
<Button variant="secondary" size="sm" aria-label="Rotate right" onClick={() => rotateView.current(Math.PI / 8)}><ArrowRightIcon aria-hidden className="h-3 w-3" /></Button>
<Button variant="secondary" size="sm" aria-label="Zoom in" onClick={() => zoomView.current(0.8)}><PlusIcon aria-hidden className="h-3 w-3" /></Button>
<Button variant="secondary" size="sm" aria-label="Zoom out" onClick={() => zoomView.current(1.25)}><MinusIcon aria-hidden className="h-3 w-3" /></Button>
{kind === "splat" && <Button variant="secondary" size="sm" onClick={() => frameView.current()}>Frame all</Button>}
<Button variant="secondary" size="sm" onClick={() => resetView.current()}><RotateCcwIcon aria-hidden className="h-3 w-3" />Reset view</Button>
</div>
</div>
)}
</div>
);
}
async function loadMesh(url: string, format: string): Promise<{ model: THREE.Object3D; animations: THREE.AnimationClip[] }> {
if (format === "fbx") {
const { FBXLoader } = await import("three/examples/jsm/loaders/FBXLoader.js");
const model = await new FBXLoader().loadAsync(url);
return { model, animations: model.animations };
}
if (format === "obj") {
const { OBJLoader } = await import("three/examples/jsm/loaders/OBJLoader.js");
return { model: await new OBJLoader().loadAsync(url), animations: [] };
}
if (format === "ply" || format === "stl") {
const geometry = format === "ply"
? await new (await import("three/examples/jsm/loaders/PLYLoader.js")).PLYLoader().loadAsync(url)
: await new (await import("three/examples/jsm/loaders/STLLoader.js")).STLLoader().loadAsync(url);
geometry.computeVertexNormals();
return { model: new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0xb9c5d4, vertexColors: !!geometry.getAttribute("color"), side: THREE.DoubleSide })), animations: [] };
}
if (!["glb", "gltf", ""].includes(format)) throw new Error("Unsupported mesh format");
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync(url);
return { model: gltf.scene, animations: gltf.animations };
}
function disposeModel(model: THREE.Object3D) {
const textures = new Set<THREE.Texture>();
model.traverse((node) => {
const mesh = node as THREE.Mesh;
mesh.geometry?.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
for (const material of materials) {
for (const value of Object.values(material)) if (value instanceof THREE.Texture) textures.add(value);
material.dispose();
}
});
textures.forEach((texture) => texture.dispose());
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import gsap from "gsap";
import { HERO_SECTION_ID, RULER_SECTIONS } from "@/lib/landing-data";
import {
@@ -47,7 +48,21 @@ import {
// Progressive enhancement: fine pointers on md+ get the full instrument
// (native scrollbar hidden); reduced-motion visitors get a static rail and
// keep their scrollbar; coarse pointers and small screens get nothing.
// Routes that render without the instrument: utility pages that are a
// single viewport-tall surface rather than a scrolling story.
const RULERLESS_ROUTES = ["/media"];
export default function ScrollRuler() {
const pathname = usePathname();
const rulerless = RULERLESS_ROUTES.some(
(route) => pathname === route || pathname.startsWith(`${route}/`),
);
// Unmounting (not hiding) so the effects release the native scrollbar.
return rulerless ? null : <ScrollRulerInstrument />;
}
function ScrollRulerInstrument() {
const [mode, setMode] = useState<RulerMode | null>(null);
const [geom, setGeom] = useState({ docH: 0, vh: 0, vw: 0 });
const [layoutVersion, setLayoutVersion] = useState(0);
@@ -16,7 +16,7 @@ const SESSION_STORAGE_KEY = "artcraft_signed_session";
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; errorMessage: string };
| { success: false; errorMessage: string; status?: number };
export type SessionUser = {
user_token: string;
@@ -270,9 +270,9 @@ function portalRequest(
type Envelope = { success: boolean; error_message?: string; message?: string };
async function request<T extends object>(
export async function request<T extends object>(
path: string,
init: { method?: "GET" | "POST"; body?: unknown } = {},
init: { method?: "GET" | "POST"; body?: unknown; signal?: AbortSignal } = {},
): Promise<ApiResult<T>> {
try {
const headers: Record<string, string> = {
@@ -286,6 +286,7 @@ async function request<T extends object>(
method: init.method ?? "GET",
headers,
credentials: "include",
signal: init.signal,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
@@ -295,6 +296,7 @@ async function request<T extends object>(
if (!response.ok) {
return {
success: false,
status: response.status,
errorMessage:
payload?.message ?? payload?.error_message ?? `Request failed (${response.status})`,
};
@@ -2,6 +2,7 @@
// config/github_download_links.ts. Keep previous releases in the history
// when adding new downloads, newest first.
const DOWNLOAD_HISTORY = {
"0.41.0": githubDownloadLinks("0.41.0"),
"0.37.0": githubDownloadLinks("0.37.0"),
"0.32.0": githubDownloadLinks("0.32.0"),
"0.28.0": githubDownloadLinks("0.28.0"),
@@ -13,8 +14,8 @@ const DOWNLOAD_HISTORY = {
} as const;
// To roll back either platform, select a version from DOWNLOAD_HISTORY.
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
// Set false to temporarily hide desktop downloads throughout the website.
export const DOWNLOADS_ENABLED = true;
@@ -0,0 +1,24 @@
import { API_HOST } from "./api";
import { mediaFilePath, safeMediaUrl, type SharedMedia } from "./media";
// Social cards use only the public API response. Never forward a visitor's
// cookies or signed session into server metadata; private media stays client-side.
export async function fetchPublicMediaFile(token: string): Promise<SharedMedia | null> {
try {
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const payload = await response.json() as { success?: boolean; media_file?: SharedMedia };
const media = payload.media_file;
if (!payload.success || media?.token !== token || !safeMediaUrl(media.media_links?.cdn_url)) return null;
// Hidden means unlisted but shareable by URL. Private files must never
// appear in social cards, even if an API response includes their fields.
return media.creator_set_visibility === "public" || media.creator_set_visibility === "hidden" ? media : null;
} catch {
return null;
}
}
@@ -0,0 +1,24 @@
import { request, type ApiResult } from "./api";
import { mediaFilePath, safeMediaUrl, type MediaPrompt, type SharedMedia } from "./media";
export async function getSharedMedia(token: string, signal?: AbortSignal): Promise<ApiResult<SharedMedia>> {
const result = await request<{ media_file?: SharedMedia }>(
mediaFilePath(token), { signal },
);
if (!result.success) return result;
const media = result.data.media_file;
if (!media?.token || !safeMediaUrl(media.media_links?.cdn_url)) {
return { success: false, errorMessage: "This media file has no available preview." };
}
return { success: true, data: media };
}
export async function getMediaPrompt(token: string, signal?: AbortSignal): Promise<ApiResult<MediaPrompt>> {
const result = await request<{ prompt?: MediaPrompt }>(
`/v1/prompts/${encodeURIComponent(token)}`, { signal },
);
if (!result.success) return result;
return result.data.prompt
? { success: true, data: result.data.prompt }
: { success: false, errorMessage: "Prompt unavailable" };
}
@@ -0,0 +1,35 @@
import { mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type SharedMedia } from "./media";
export const MEDIA_SHARE_ORIGIN = "https://getartcraft.com";
const PREVIEW_WIDTH = 1200;
const VIDEO_TYPES: Record<string, string> = { mp4: "video/mp4", webm: "video/webm" };
const AUDIO_TYPES: Record<string, string> = {
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac",
ogg: "audio/ogg", opus: "audio/ogg", flac: "audio/flac",
};
type MediaEmbed = {
kind: "video" | "audio";
url: string;
contentType: string;
// These are iframe viewport dimensions, not invented source dimensions.
width: number;
height: number;
};
export function mediaEmbed(media: SharedMedia): MediaEmbed | null {
const kind = mediaKind(media);
if (kind !== "video" && kind !== "audio") return null;
const contentType = (kind === "video" ? VIDEO_TYPES : AUDIO_TYPES)[mediaFormat(media)];
const url = safeMediaUrl(media.media_links.cdn_url);
if (!contentType || !url?.startsWith("https://")) return null;
return { kind, contentType, url, width: 640, height: kind === "video" ? 360 : 180 };
}
export function mediaShareImage(media: SharedMedia): string | undefined {
const cover = media.cover_image?.maybe_links;
return mediaThumbnail(media.media_links, PREVIEW_WIDTH)
?? (mediaKind(media) === "image" ? safeMediaUrl(media.media_links.cdn_url) : undefined)
?? safeMediaUrl(cover?.thumbnail_template?.replace("{WIDTH}", String(PREVIEW_WIDTH)))
?? safeMediaUrl(cover?.cdn_url);
}
@@ -0,0 +1,131 @@
export type MediaKind = "image" | "video" | "audio" | "mesh" | "splat" | "unsupported";
export type MediaDimensions = { width: number; height: number };
// Fields from storyteller-web's GET /v1/media_files/file/{token} response.
export type SharedMedia = {
token: string;
media_class?: string | null;
media_type?: string | null;
maybe_engine_category?: string | null;
maybe_title?: string | null;
creator_set_visibility?: string | null;
maybe_original_filename?: string | null;
maybe_prompt_token?: string | null;
maybe_text_transcript?: string | null;
maybe_duration_millis?: number | null;
maybe_creator_user?: {
username: string;
display_name?: string;
} | null;
maybe_model_weight_info?: { title: string } | null;
created_at?: string;
cover_image?: {
maybe_links?: { cdn_url: string; thumbnail_template?: string | null } | null;
} | null;
media_links: {
cdn_url: string;
maybe_thumbnail_template?: string | null;
maybe_video_previews?: { still: string; still_thumbnail_template?: string | null } | null;
};
};
export type MediaPrompt = {
maybe_positive_prompt?: string | null;
maybe_negative_prompt?: string | null;
maybe_model_type?: string | null;
maybe_generation_provider?: string | null;
maybe_aspect_ratio?: string | null;
maybe_resolution?: string | null;
maybe_duration_seconds?: number | null;
maybe_generate_audio?: boolean | null;
maybe_context_images?: {
media_token: string;
semantic?: string;
media_links: SharedMedia["media_links"];
}[] | null;
};
export const MEDIA_LABELS: Record<MediaKind, string> = {
image: "Image", video: "Video", audio: "Audio", mesh: "3D mesh",
splat: "3D splat", unsupported: "Media file",
};
export function mediaKind(media: SharedMedia): MediaKind {
// Class is authoritative: PLY can be either a mesh or a Gaussian splat.
switch (media.media_class) {
case "image": case "video": case "audio": case "mesh": case "splat":
return media.media_class;
case "project": return "unsupported";
}
if (media.maybe_engine_category === "splat") return "splat";
const format = mediaFormat(media);
if (["spz", "splat", "ksplat", "ply"].includes(format)) return "splat";
if (["glb", "gltf", "fbx", "obj", "stl", "pmx", "pmd"].includes(format)) return "mesh";
if (["image", "png", "jpg", "jpeg", "webp", "gif", "avif"].includes(format)) return "image";
if (["video", "mp4", "webm", "mov", "m4v"].includes(format)) return "video";
if (["audio", "wav", "mp3", "m4a", "aac", "ogg", "opus", "flac"].includes(format)) return "audio";
return "unsupported";
}
export function mediaFormat(media: SharedMedia): string {
const type = media.media_type?.toLowerCase();
if (type && !["unknown", "image", "video", "audio", "dimensional"].includes(type)) return type;
try {
return new URL(media.media_links.cdn_url).pathname.match(/\.([a-z0-9]+)$/i)?.[1].toLowerCase() ?? type ?? "";
} catch {
return type ?? "";
}
}
export function safeMediaUrl(value?: string | null): string | undefined {
if (!value) return undefined;
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
} catch {
return undefined;
}
}
export function corsMediaUrl(value: string): string {
const url = new URL(value);
// Match the old CDN CORS opt-in without corrupting existing query parameters.
url.searchParams.set("cors", "1");
return url.href;
}
export function mediaPoster(media: SharedMedia): string | undefined {
return mediaThumbnail(media.media_links, 1280);
}
// Video references need a still frame, not the raw video URL in an <img>.
// The same thumbnail selection is used for social previews and the player.
export function mediaThumbnail(links: SharedMedia["media_links"], width: number): string | undefined {
const previews = links.maybe_video_previews;
const template = links.maybe_thumbnail_template ?? previews?.still_thumbnail_template;
return safeMediaUrl(template?.replace("{WIDTH}", String(width))) ?? safeMediaUrl(previews?.still);
}
export function mediaFilePath(token: string): string {
return `/v1/media_files/file/${encodeURIComponent(token)}`;
}
export function mediaTitle(media: SharedMedia): string {
const kind = mediaKind(media);
const label = kind === "mesh" || kind === "splat" ? MEDIA_LABELS[kind] : MEDIA_LABELS[kind].toLowerCase();
return media.maybe_title?.trim() || media.maybe_original_filename?.trim() || `Untitled ${label}`;
}
export function mediaDetailLabel(value?: string | null): string | undefined {
if (!value) return undefined;
const labels: Record<string, string> = {
artcraft: "ArtCraft", square: "1:1", square_hd: "1:1 (HD)",
wide_three_by_two: "3:2", wide_four_by_three: "4:3", wide_five_by_four: "5:4",
wide_sixteen_by_nine: "16:9", wide_twenty_one_by_nine: "21:9",
tall_two_by_three: "2:3", tall_three_by_four: "3:4", tall_four_by_five: "4:5",
tall_nine_by_sixteen: "9:16", tall_nine_by_twenty_one: "9:21",
half_k: "0.5K", one_k: "1K", two_k: "2K", three_k: "3K", four_k: "4K",
four_eighty_p: "480p", seven_twenty_p: "720p", ten_eighty_p: "1080p",
};
return labels[value] ?? value.replace(/(\d)p(\d)/g, "$1.$2").replaceAll("_", " ").replace(/\b[a-z]/g, (letter) => letter.toUpperCase()).replace(/\b3d\b/gi, "3D");
}
@@ -0,0 +1,226 @@
// Model / provider display names and brand marks, ported from the shared
// @storyteller/model-list metadata (ModelMapping, ModelCreatorIconForId).
// Marks are the services SVGs in public/images/services/.
import { mediaUrl } from "./links";
const MODEL_DISPLAY_NAMES: Record<string, string> = {
// Grok
grok_image: "Grok Image",
grok_imagine_image: "Grok Imagine",
grok_imagine_image_q: "Grok Imagine Quality",
grok_video: "Grok Video",
grok_imagine_video: "Grok Imagine",
grok_imagine_video_1p5: "Grok Imagine 1.5",
// Flux (Black Forest Labs)
flux_1_dev: "Flux 1 Dev",
flux_1_schnell: "Flux 1 Schnell",
flux_pro_1p1: "Flux Pro 1.1",
flux_pro_1p1_ultra: "Flux Pro 1.1 Ultra",
flux_pro_kontext_max: "Flux Pro Kontext Max",
flux_dev_juggernaut: "Flux Dev Juggernaut",
flux_pro_1: "Flux Pro (Inpainting)",
flux_3: "Flux 3",
flux_3_draft: "Flux 3 Draft",
flux_pro_1_1: "Flux Pro 1.1",
flux_pro_1_1_ultra: "Flux Pro 1.1 Ultra",
flux_2_lora_angles: "Flux 2 LoRA Angles",
// OpenAI
gpt_image_1: "GPT Image 1",
gpt_image_1p5: "GPT Image 1.5",
gpt_image_2: "GPT Image 2",
gpt_image_2p5_flare: "GPT Image 2.5 Flare",
gpt_image_2p5_sunburst: "GPT Image 2.5 Sunburst",
sora_2: "Sora 2",
sora_2_pro: "Sora 2 Pro",
// Kling
kling_1p6_pro: "Kling 1.6 Pro",
kling_2p1_pro: "Kling 2.1 Pro",
kling_2p1_master: "Kling 2.1 Master",
kling_2p5_turbo_pro: "Kling 2.5 Turbo Pro",
kling_2p6_pro: "Kling 2.6 Pro",
kling_3p0_standard: "Kling 3.0 Standard",
kling_3p0_pro: "Kling 3.0 Pro",
kling_1_6_pro: "Kling 1.6 Pro",
kling_2_1_pro: "Kling 2.1 Pro",
kling_2_1_master: "Kling 2.1 Master",
// Seedance (ByteDance)
seedance_1p0_lite: "Seedance 1.0 Lite",
seedance_1p0_pro: "Seedance 1.0 Pro",
seedance_1p5_pro: "Seedance 1.5 Pro",
seedance_2p0: "Seedance 2.0",
seedance_2p0_fast: "Seedance 2.0 Fast",
seedance_2p0_mini: "Seedance 2.0 Mini",
seedance_2p0_bp: "Seedance 2.0 Plus",
seedance_2p0_bp_fast: "Seedance 2.0 Plus Fast",
seedance_2p0_bp_mini: "Seedance 2.0 Plus Mini",
seedance_2p0_bpu: "Seedance 2.0 Plus Ultra",
seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast",
seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini",
seedance_2p5: "Seedance 2.5",
seedance_2p5_preview: "Seedance 2.5 Preview",
seedance_1_0_lite: "Seedance 1.0 Lite",
preview_model: "Preview Model",
preview_model_fast: "Preview Model Fast",
// Seedream / SeedEdit (ByteDance)
seedream_4: "Seedream 4",
seedream_4p5: "Seedream 4.5",
seedream_5_lite: "Seedream 5 Lite",
seedream_5p0_pro: "Seedream 5.0 Pro",
seedream_5p0_pro_u: "Seedream 5.0 Pro Ultra",
seededit_3: "SeedEdit 3",
seed_audio_1p0: "Seed Audio 1.0",
// Alibaba
happy_horse_1p0: "Happy Horse 1.0",
qwen_edit_2511_angles: "Qwen Edit 2511 Angles",
// MiniMax (Hailuo)
minimax_h3: "MiniMax H3",
// Suno
suno_music: "Suno Music",
suno_remix: "Suno Remix",
suno_sounds: "Suno Sounds",
suno_sample: "Suno Sample",
// Google
veo_2: "Google Veo 2",
veo_3: "Google Veo 3",
veo_3_fast: "Google Veo 3 Fast",
veo_3p1: "Google Veo 3.1",
veo_3p1_fast: "Google Veo 3.1 Fast",
veo_3p1_lite: "Google Veo 3.1 Lite",
gemini_25_flash: "Nano Banana",
nano_banana: "Nano Banana",
nano_banana_2: "Nano Banana 2",
nano_banana_pro: "Nano Banana Pro",
// Recraft
recraft_3: "Recraft 3",
// Vidu
vidu_q3: "Vidu Q3",
vidu_q3_turbo: "Vidu Q3 Turbo",
// Hunyuan (Tencent)
hunyuan_3d: "Hunyuan 3D",
hunyuan_3d_2: "Hunyuan 3D 2.0",
hunyuan_3d_2p0: "Hunyuan 3D 2.0",
hunyuan_3d_2p1: "Hunyuan 3D 2.1",
hunyuan_3d_3: "Hunyuan 3D 3.0",
hunyuan_3d_3_sketch: "Hunyuan 3D 3 Sketch",
hunyuan_3d_3p1_pro: "Hunyuan 3D 3.1 Pro",
hunyuan_3d_3p1_rapid: "Hunyuan 3D 3.1 Rapid",
hunyuan_3d_3p1_part: "Hunyuan 3D 3.1 Part",
hunyuan_3d_3p1_topology: "Hunyuan 3D 3.1 Smart Topology",
hunyuan_3d_2_0: "Hunyuan 3D 2.0",
hunyuan_3d_2_1: "Hunyuan 3D 2.1",
// Other 3D
tripo3d_h3p1: "Tripo3D H3.1",
triposplat: "TripoSplat",
meshy_v6: "Meshy 6",
rodin_2p5_fast: "Rodin 2.5 Fast",
// World Labs
worldlabs_gaussian: "World Labs Marble",
marble_0p1_mini: "Marble Mini",
marble_0p1_plus: "Marble Plus",
marble_1p0: "Marble 1.0",
marble_1p0_draft: "Marble 1.0 Draft",
marble_1p1: "Marble 1.1",
marble_1p1_plus: "Marble 1.1 Plus",
// Midjourney
midjourney: "Midjourney",
midjourney_v6: "Midjourney V6",
midjourney_v6p1: "Midjourney V6.1",
midjourney_v6p1_raw: "Midjourney V6.1 (Raw)",
midjourney_v7: "Midjourney V7",
midjourney_v7_raw: "Midjourney V7 (Raw)",
midjourney_v7_draft: "Midjourney V7 (Draft)",
midjourney_v7_draft_raw: "Midjourney V7 (Draft Raw)",
midjourney_7: "Midjourney v7",
midjourney_7_niji: "Midjourney v7 Niji (Anime)",
midjourney_8: "Midjourney v8",
// Beeble
switch_x: "Beeble SwitchX",
};
// Model-id prefix → services icon file. Order matters: first match wins.
const MODEL_ID_PREFIX_ICONS: [string, string][] = [
["flux", "blackforestlabs.svg"],
["nano_banana", "google.svg"],
["gemini", "google.svg"],
["veo", "google.svg"],
["gpt_image", "openai.svg"],
["sora", "openai.svg"],
["midjourney", "midjourney.svg"],
["seedream", "bytedance.svg"],
["seedance", "bytedance.svg"],
["seededit", "bytedance.svg"],
["seed_audio", "bytedance.svg"],
["kling", "kling.svg"],
["grok", "grok.svg"],
["vidu", "vidu.svg"],
["recraft", "recraft.svg"],
["happy_horse", "alibaba.svg"],
["wan", "alibaba.svg"],
["qwen", "alibaba.svg"],
["minimax", "minimax.svg"],
["suno", "suno.svg"],
["hunyuan", "tencent.svg"],
["marble", "worldlabs.svg"],
["worldlabs", "worldlabs.svg"],
["switch_x", "artcraft.svg"],
];
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
artcraft: "ArtCraft",
fal: "FAL",
grok: "Grok",
midjourney: "Midjourney",
sora: "Sora",
worldlabs: "World Labs",
};
const PROVIDER_ICONS: Record<string, string> = {
artcraft: "artcraft.svg",
fal: "fal.svg",
grok: "grok.svg",
midjourney: "midjourney.svg",
sora: "openai.svg",
worldlabs: "worldlabs.svg",
};
const serviceIcon = (file: string) => mediaUrl(`/images/services/${file}`);
// Lowercase, dots → underscores, matching the shared lib's normalizer.
const normalizeModelKey = (modelType: string): string =>
modelType.toLowerCase().replace(/\./g, "_").trim();
export function getModelDisplayName(modelType: string): string {
return MODEL_DISPLAY_NAMES[normalizeModelKey(modelType)] ?? modelType;
}
export function getModelIcon(modelType: string): string {
const key = normalizeModelKey(modelType);
const match = MODEL_ID_PREFIX_ICONS.find(([prefix]) => key.startsWith(prefix));
return serviceIcon(match?.[1] ?? "generic.svg");
}
export function getProviderDisplayName(provider: string): string {
return PROVIDER_DISPLAY_NAMES[provider.toLowerCase()] ?? provider;
}
export function getProviderIcon(provider: string): string | null {
const file = PROVIDER_ICONS[provider.toLowerCase()];
return file ? serviceIcon(file) : null;
}
@@ -0,0 +1,44 @@
# Media fixtures
`clip.mp4` is a generated one-second solid-blue H.264 video with no audio:
```sh
ffmpeg -f lavfi -i color=c=0x3366cc:s=64x64:d=1 -c:v libx264 -pix_fmt yuv420p -movflags +faststart clip.mp4
```
The tests construct their tiny PNG, sine-wave WAV, triangle GLB, and 27-point
Gaussian splat fixtures locally. All Storyteller API and media CDN requests are
intercepted in the browser; server-side social-card lookups use `api-server.mjs`
on `127.0.0.1:4203`, which has no upstream. No production API or database is used.
Run `npm run test:media` with Google Chrome installed and ports 4202 and 4203 free.
The suite starts its own Next server with the fixture API host, so it refuses to
reuse a running development server. Playwright uses installed Chrome.
`media-metadata.spec.ts` inspects raw HTTP response heads for Discord, Twitter,
Meta, Slack, an unknown crawler, curl, and a browser user agent. It executes no
page JavaScript. The fixtures cover image titles, video stills, 3D cover images,
unlisted links, audio without artwork, private media, API errors/timeouts, and
updates to an existing creation. The fixture API rejects forwarded session
credentials. Keep these tests independent of the browser's intercepted requests.
The route's `generateMetadata` performs the anonymous server-side lookup.
`htmlLimitedBots: /.*/` keeps metadata in the initial `<head>` for every user
agent. Keep the five-second API timeout and generic fallback so an API outage
does not leave crawlers waiting indefinitely. `cache: "no-store"` avoids stale
server metadata; social platforms can still cache cards independently.
Public audio/video links also advertise a standalone `/media/<token>/player`
document and `/media/<token>/oembed` discovery endpoint. The player uses native
HTML controls without scripts or the marketing layout. Tests play its fixture
video and audio in Chrome with page JavaScript disabled, check oEmbed sizing,
and verify that private media cannot be embedded. Twitter's optional native
stream URL is emitted only for MP4; audio and WebM use the iframe player.
These tests verify our responses and native player, not another platform's
acceptance of an embed. Validate a deployed URL in each platform. Discord's
audio-tag support is still an open request:
https://github.com/discord/discord-api-docs/discussions/3262
Discord-playable audio needs a generated video derivative (for example, H.264
cover art plus AAC audio in an MP4). Merely declaring an MP3 to be a video does
not create that derivative. Instagram feed/Reel publishing is a separate media
upload workflow; website metadata does not publish an Instagram post.
@@ -0,0 +1,82 @@
import { createServer } from "node:http";
// Next's server-side metadata requests cannot be intercepted by page.route.
// This local-only fixture has no upstream and never connects to a database.
const CDN = "https://media-fixture.invalid";
const publicMedia = {
m_fixture_image: {
token: "m_fixture_image", media_class: "image",
creator_set_visibility: "public", maybe_title: "Night sky & stars",
maybe_creator_user: { username: "fixture_artist", display_name: "Fixture Artist" },
media_links: { cdn_url: `${CDN}/asset.png?fixture=1` },
},
m_fixture_video: {
token: "m_fixture_video", media_class: "video",
creator_set_visibility: "public", maybe_title: "A short film",
media_links: { cdn_url: `${CDN}/asset.mp4`, maybe_video_previews: {
still: `${CDN}/still.jpg`, still_thumbnail_template: `${CDN}/still-{WIDTH}.jpg`,
} },
},
m_fixture_audio: {
token: "m_fixture_audio", media_class: "audio",
creator_set_visibility: "public", maybe_title: "A song",
media_links: { cdn_url: `${CDN}/asset.wav` },
},
m_fixture_mesh: {
token: "m_fixture_mesh", media_class: "mesh", creator_set_visibility: "public",
media_links: { cdn_url: `${CDN}/asset.glb` },
cover_image: { maybe_links: { cdn_url: `${CDN}/cover.jpg`, thumbnail_template: `${CDN}/cover-{WIDTH}.jpg` } },
},
m_fixture_splat: {
token: "m_fixture_splat", media_class: "splat", creator_set_visibility: "public",
media_links: { cdn_url: `${CDN}/asset.spz` },
cover_image: { maybe_links: { cdn_url: `${CDN}/splat-cover.jpg` } },
},
m_fixture_private: {
token: "m_fixture_private", media_class: "image", creator_set_visibility: "private",
maybe_title: "Secret creation", maybe_creator_user: { username: "secret_artist" },
media_links: { cdn_url: `${CDN}/secret-image.png` },
},
m_fixture_hidden: {
token: "m_fixture_hidden", media_class: "image", creator_set_visibility: "hidden",
media_links: { cdn_url: `${CDN}/unlisted.png`, maybe_thumbnail_template: `${CDN}/unlisted-{WIDTH}.png` },
},
};
let revision = 0;
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
if (request.url === "/health") {
response.end(JSON.stringify({ success: true }));
return;
}
// A regression forwarding visitor credentials must not produce public cards.
if (request.headers.cookie || request.headers.authorization || request.headers.session) {
response.writeHead(403).end(JSON.stringify({ success: false }));
return;
}
const token = request.url?.match(/^\/v1\/media_files\/file\/([^/?]+)$/)?.[1];
if (token === "m_fixture_changing") {
response.end(JSON.stringify({ success: true, media_file: {
...publicMedia.m_fixture_image, token, maybe_title: `Revision ${++revision}`,
} }));
return;
}
if (token === "m_fixture_malformed") {
response.end("not json");
return;
}
if (token === "m_fixture_timeout") {
setTimeout(() => response.end(JSON.stringify({ success: false })), 6000);
return;
}
const errorStatus = { m_fixture_unauthorized: 401, m_fixture_forbidden: 403, m_fixture_failure: 500 }[token];
if (errorStatus) {
response.writeHead(errorStatus).end(JSON.stringify({ success: false }));
return;
}
const media = publicMedia[token];
response.writeHead(media ? 200 : 404).end(JSON.stringify(media
? { success: true, media_file: media }
: { success: false }));
}).listen(4203, "127.0.0.1");
@@ -0,0 +1,10 @@
export function wav() {
const buffer = Buffer.alloc(44 + 80000);
buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(8000, 24); buffer.writeUInt32LE(16000, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34);
buffer.write("data", 36); buffer.writeUInt32LE(80000, 40);
for (let i = 0; i < 40000; i++) buffer.writeInt16LE(Math.round(Math.sin(i * 2 * Math.PI * 440 / 8000) * 12000), 44 + i * 2);
return buffer;
}
Binary file not shown.
@@ -0,0 +1,171 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { wav } from "./fixtures/audio";
const CDN = "https://media-fixture.invalid";
const SITE = "https://getartcraft.com";
const CRAWLERS = [
["Discord", "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"],
["Twitter", "Twitterbot/1.0"],
["Meta", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"],
["Slack", "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"],
["unknown crawler", "ArtCraftPreviewTest/1.0"],
["curl", "curl/8.7.1"],
["browser", "Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36"],
] as const;
// Raw HTTP only: no browser, hydration, JavaScript, or DOM repair can make
// these assertions pass. Tags must be inside the server response's <head>.
for (const [name, userAgent] of CRAWLERS) {
test(`${name} gets complete media social tags in the raw HTML head`, async ({ request }) => {
const head = await requestHead(request, "m_fixture_image", userAgent);
expect(head).toContain("<title>Night sky &amp; stars — ArtCraft</title>");
expect(head).toContain('property="og:title" content="Night sky &amp; stars — ArtCraft"');
expect(head).toContain('property="og:description" content="Made with ArtCraft by Fixture Artist."');
expect(head).toContain('property="og:type" content="website"');
expect(head).toContain('property="og:site_name" content="ArtCraft"');
expect(head).toContain(`property="og:url" content="${SITE}/media/m_fixture_image"`);
expect(head).toContain(`property="og:image" content="${CDN}/asset.png?fixture=1"`);
expect(head).toContain('property="og:image:alt" content="Night sky &amp; stars"');
expect(head).toContain('name="twitter:card" content="summary_large_image"');
expect(head).toContain('name="twitter:title" content="Night sky &amp; stars — ArtCraft"');
expect(head).toContain('name="twitter:description" content="Made with ArtCraft by Fixture Artist."');
expect(head).toContain(`name="twitter:image" content="${CDN}/asset.png?fixture=1"`);
expect(head).toContain(`rel="canonical" href="${SITE}/media/m_fixture_image"`);
expect(head).toContain('name="robots" content="noindex, follow"');
});
}
test("video, mesh, splat, and unlisted cards use image previews", async ({ request }) => {
for (const [token, preview] of [
["m_fixture_video", "still-1200.jpg"],
["m_fixture_mesh", "cover-1200.jpg"],
["m_fixture_splat", "splat-cover.jpg"],
["m_fixture_hidden", "unlisted-1200.png"],
]) {
const head = await requestHead(request, token);
expect(head).toContain(`property="og:image" content="${CDN}/${preview}"`);
expect(head).toContain(`name="twitter:image" content="${CDN}/${preview}"`);
}
});
test("audio without cover art uses the site image and its own title", async ({ request }) => {
const head = await requestHead(request, "m_fixture_audio");
expect(head).toContain('property="og:title" content="A song — ArtCraft"');
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
expect(head).not.toContain(`property="og:image" content="${CDN}/asset.wav"`);
});
test("video and audio advertise direct media and iframe players", async ({ request }) => {
for (const [, userAgent] of CRAWLERS) {
const video = await requestHead(request, "m_fixture_video", userAgent);
expect(video).toContain('property="og:type" content="video.other"');
expect(video).toContain(`property="og:video" content="${CDN}/asset.mp4"`);
expect(video).toContain(`property="og:video:secure_url" content="${CDN}/asset.mp4"`);
expect(video).toContain('property="og:video:type" content="video/mp4"');
expect(video).toContain('name="twitter:card" content="player"');
expect(video).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_video/player"`);
expect(video).toContain(`name="twitter:player:stream" content="${CDN}/asset.mp4"`);
expect(video).toContain('name="twitter:player:stream:content_type" content="video/mp4"');
expect(video).toContain('name="twitter:player:width" content="640"');
expect(video).toContain('name="twitter:player:height" content="360"');
expect(video).toContain('type="application/json+oembed"');
const audio = await requestHead(request, "m_fixture_audio", userAgent);
expect(audio).toContain(`property="og:audio" content="${CDN}/asset.wav"`);
expect(audio).toContain('property="og:audio:type" content="audio/wav"');
expect(audio).toContain('name="twitter:card" content="player"');
expect(audio).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_audio/player"`);
expect(audio).not.toContain('name="twitter:player:stream"');
expect(audio).not.toContain('property="og:video"');
}
});
test("oEmbed returns a sized iframe and rejects unrelated URLs and unsupported formats", async ({ request }) => {
for (const [token, type] of [["m_fixture_video", "video"], ["m_fixture_audio", "rich"]]) {
const response = await request.get(`/media/${token}/oembed`, { params: { url: `${SITE}/media/${token}`, format: "json", maxwidth: 320, maxheight: 90 } });
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toMatchObject({ version: "1.0", type, provider_name: "ArtCraft", provider_url: SITE });
expect(data.width).toBeLessThanOrEqual(320);
expect(data.height).toBeLessThanOrEqual(90);
expect(data.html).toContain(`src="${SITE}/media/${token}/player"`);
expect(data.html).toContain(`width="${data.width}" height="${data.height}"`);
}
const unrelated = await request.get("/media/m_fixture_video/oembed", { params: { url: "https://unrelated.invalid/" } });
expect(unrelated.status()).toBe(404);
const xml = await request.get("/media/m_fixture_video/oembed?format=xml");
expect(xml.status()).toBe(501);
});
test("private and non-playable media cannot be embedded", async ({ request }) => {
for (const token of ["m_fixture_private", "m_missing", "m_fixture_image", "m_fixture_mesh"]) {
for (const route of ["player", "oembed"]) {
const response = await request.get(`/media/${token}/${route}`, { params: { url: `${SITE}/media/${token}` } });
expect(response.status()).toBe(404);
const body = await response.text();
expect(body).not.toContain(CDN);
expect(body).not.toContain("Secret creation");
}
}
});
test("standalone video and audio players load and play with JavaScript disabled", async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
try {
const page = await context.newPage();
const png = readFileSync(join(__dirname, "../public/images/og-image.png"));
await page.route(`${CDN}/**`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`${SITE}/images/og-image.png`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`${CDN}/asset.mp4`, (route) => route.fulfill({ body: readFileSync(join(__dirname, "fixtures/clip.mp4")), contentType: "video/mp4" }));
await page.route(`${CDN}/asset.wav`, (route) => route.fulfill({ body: wav(), contentType: "audio/wav" }));
for (const kind of ["video", "audio"]) {
const response = await page.goto(`http://localhost:4202/media/m_fixture_${kind}/player`);
expect(response?.headers()["content-security-policy"]).toContain("default-src 'none'");
expect(await response!.text()).not.toContain("<script");
const player = page.locator(kind);
await expect(player).toHaveAttribute("controls", "");
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.readyState)).toBeGreaterThanOrEqual(1);
// Invoke the native media API; the document itself contains no JavaScript.
await player.evaluate((element: HTMLMediaElement) => { element.muted = true; return element.play(); });
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.currentTime)).toBeGreaterThan(0);
}
} finally {
await context.close();
}
});
test("private files, missing files, and API failures get a generic card", async ({ request }) => {
for (const token of ["m_fixture_private", "m_missing", "m_fixture_unauthorized", "m_fixture_forbidden", "m_fixture_failure", "m_fixture_malformed", "m_fixture_timeout"]) {
const head = await requestHead(request, token);
expect(head).toContain('property="og:title" content="Shared media — ArtCraft"');
expect(head).toContain(`property="og:url" content="${SITE}/media/${token}"`);
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
expect(head).not.toContain(CDN);
expect(head).not.toContain("Secret creation");
expect(head).not.toContain("secret_artist");
}
});
test("metadata is looked up again when a public creation changes", async ({ request }) => {
const first = await requestHead(request, "m_fixture_changing");
const second = await requestHead(request, "m_fixture_changing");
const revision = (head: string) => Number(head.match(/property="og:title" content="Revision (\d+)/)?.[1]);
expect(revision(first)).toBeGreaterThan(0);
expect(revision(second)).toBeGreaterThan(revision(first));
});
async function requestHead(request: APIRequestContext, token: string, userAgent = "Discordbot/2.0") {
const response = await request.get(`/media/${token}`, {
// The fixture API rejects these headers if the website forwards them.
headers: { "User-Agent": userAgent, Cookie: "session=private-fixture", session: "private-fixture" },
});
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("text/html");
const html = await response.text();
const head = html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i)?.[1];
expect(head).toBeDefined();
return head!;
}
@@ -0,0 +1,277 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { wav } from "./fixtures/audio";
import { corsMediaUrl, mediaKind, safeMediaUrl, type SharedMedia } from "../src/lib/media";
const API = "http://127.0.0.1:4203";
const CDN = "https://media-fixture.invalid";
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1kAAAAASUVORK5CYII=", "base64");
test.beforeEach(async ({ page }) => {
// All API calls are fulfilled locally, including navbar/session/referral
// requests. Next's server-side metadata lookup uses the local fixture API.
// These tests never reach production or a database.
await page.route("**/v1/**", (route) => route.fulfill({ json: { success: true, logged_in: false } }));
await page.route(`${CDN}/**`, (route) => route.fulfill({ body: PNG, contentType: "image/png" }));
});
test("direct shared image URL loads without login and survives refresh", async ({ page }) => {
await mockMedia(page, fixture("image", "png"));
await page.goto("/media/m_fixture_image");
await expect(page.getByRole("heading", { name: "Fixture image", exact: true })).toBeVisible();
await expect.poll(() => page.getByAltText("Fixture image").evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
await expect(page.getByText("@fixture_artist")).toBeVisible();
await expect(page.getByText("1 × 1", { exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Open in ArtCraft" })).toHaveAttribute("href", "https://app.getartcraft.com/media/m_fixture_image");
await page.reload();
await expect(page.getByAltText("Fixture image")).toBeVisible();
});
test("legacy query share links redirect to the canonical media page", async ({ page }) => {
await mockMedia(page, fixture("image", "png"));
await page.goto("/media?media=m_fixture_image");
await expect(page).toHaveURL((url) => url.pathname === "/media/m_fixture_image");
await expect(page.getByAltText("Fixture image")).toBeVisible();
});
test("prompt and reference media are restored without blocking the preview", async ({ page }) => {
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_fixture" });
await page.route(`${API}/v1/prompts/p_fixture`, (route) => route.fulfill({ json: {
success: true, prompt: { maybe_positive_prompt: "An ocean beneath the stars", maybe_model_type: "flux_pro",
maybe_context_images: [{ media_token: "m_reference", semantic: "Reference image", media_links: { cdn_url: `${CDN}/reference.png` } }] },
} }));
await page.goto("/media/m_fixture_image");
await expect(page.getByText("An ocean beneath the stars")).toBeVisible();
await expect(page.getByRole("link").filter({ has: page.getByAltText("Reference image") })).toHaveAttribute("href", "/media/m_reference");
});
test("prompt failure leaves the image usable", async ({ page }) => {
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_private" });
await page.route(`${API}/v1/prompts/**`, (route) => route.fulfill({ status: 403, json: { success: false } }));
await page.goto("/media/m_fixture_image");
await expect(page.getByText("Prompt details aren’t available for this creation.")).toBeVisible();
await expect(page.getByAltText("Fixture image")).toBeVisible();
});
test("model/provider brands and video reference stills appear in creation details", async ({ page }) => {
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_fixture" });
await page.route(`${API}/v1/prompts/p_fixture`, (route) => route.fulfill({ json: {
success: true, prompt: { maybe_positive_prompt: "A forest", maybe_model_type: "worldlabs_gaussian", maybe_generation_provider: "worldlabs",
maybe_context_images: [{ media_token: "m_reference_video", semantic: "Reference video", media_links: {
cdn_url: `${CDN}/reference.mp4`, maybe_video_previews: { still: `${CDN}/still.jpg`, still_thumbnail_template: `${CDN}/still-{WIDTH}.jpg` },
} }] },
} }));
await page.goto("/media/m_fixture_image");
await expect(page.getByText("World Labs Marble", { exact: true })).toBeVisible();
await expect(page.getByText("World Labs", { exact: true })).toBeVisible();
await expect(page.getByLabel("Media details").locator("img.themed-logo")).toHaveCount(2);
await expect(page.getByAltText("Reference video")).toHaveAttribute("src", `${CDN}/still-256.jpg?cors=1`);
await expect.poll(() => page.getByAltText("Reference video").evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
await expect(page.getByRole("link").filter({ has: page.getByAltText("Reference video") })).toHaveAttribute("href", "/media/m_reference_video");
});
test("video loads playable metadata and exposes native controls", async ({ page }) => {
await mockMedia(page, fixture("video", "mp4"));
await page.route(`${CDN}/asset.mp4*`, (route) => route.fulfill({ body: readFileSync(join(__dirname, "fixtures/clip.mp4")), contentType: "video/mp4" }));
await page.goto("/media/m_fixture_video");
const video = page.locator("video[aria-label='Fixture video']");
await expect(video).toHaveAttribute("controls", "");
await expect.poll(() => video.evaluate((el: HTMLVideoElement) => el.readyState)).toBeGreaterThanOrEqual(1);
await video.evaluate((el: HTMLVideoElement) => { el.muted = true; return el.play(); });
await expect.poll(() => video.evaluate((el: HTMLVideoElement) => el.currentTime)).toBeGreaterThan(0);
});
test("music loads in an audio player and shows its transcript", async ({ page }) => {
await mockMedia(page, { ...fixture("audio", "wav"), maybe_text_transcript: "Fixture song lyrics" });
await page.route(`${CDN}/asset.wav*`, (route) => serveBytes(route, wav(), "audio/wav"));
await page.goto("/media/m_fixture_audio");
const audio = page.locator("audio");
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(5);
await page.getByRole("button", { name: "Play audio" }).click();
await expect(page.getByRole("button", { name: "Pause audio" })).toBeVisible();
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await page.getByRole("button", { name: "Pause audio" }).click();
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
await page.getByRole("slider", { name: "Seek audio" }).click();
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeCloseTo(2.5, 1);
await expect(page.getByLabel("Elapsed time")).toHaveText("0:02");
await page.getByRole("slider", { name: "Audio volume" }).click();
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.volume)).toBe(0.5);
await page.getByRole("button", { name: "Mute audio", exact: true }).click();
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.muted)).toBe(true);
await page.getByRole("button", { name: "Unmute audio", exact: true }).click();
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.muted)).toBe(false);
await expect(page.getByText("Fixture song lyrics")).toBeVisible();
});
test("live waveform reflects the playing audio samples", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await mockMedia(page, fixture("audio", "wav"));
await page.route(`${CDN}/asset.wav*`, (route) => serveBytes(route, wav(), "audio/wav"));
await page.goto("/media/m_fixture_audio");
await page.getByRole("button", { name: "Play audio" }).click();
await expect.poll(async () => {
const path = await page.getByLabel("Live audio waveform").locator("path").last().getAttribute("d");
return [...(path ?? "").matchAll(/,([\d.-]+)/g)].some((match) => Math.abs(Number(match[1]) - 36) > 1);
}).toBe(true);
await page.getByRole("button", { name: "Pause audio" }).click();
await expect(page.getByRole("button", { name: "Play audio" })).toBeVisible();
});
for (const kind of ["mesh", "splat"] as const) {
test(`interactive ${kind} supports orbit, zoom, pan, and reset with no WebGL errors`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => { if (message.type() === "error" && /THREE|WebGL|shader/i.test(message.text())) errors.push(message.text()); });
const format = kind === "mesh" ? "glb" : "splat";
await mockMedia(page, fixture(kind, format));
await page.route(`${CDN}/asset.${format}*`, (route) => route.fulfill({ body: kind === "mesh" ? glb() : splats(), contentType: "application/octet-stream" }));
await page.goto(`/media/m_fixture_${kind}`);
await expect(page.getByRole("button", { name: "Reset view" })).toBeVisible();
const canvas = page.getByLabel(`Interactive 3D ${kind}`);
await expect(canvas).toBeVisible();
const box = (await canvas.boundingBox())!;
const hash = async () => createHash("sha256").update(await canvas.screenshot()).digest("hex");
let before = await hash();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 130, box.y + box.height / 2 + 40, { steps: 12 });
await page.mouse.up();
await expect.poll(hash).not.toBe(before);
before = await hash();
await page.mouse.wheel(0, -280);
await expect.poll(hash).not.toBe(before);
before = await hash();
await page.mouse.down({ button: "right" });
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2 - 40, { steps: 10 });
await page.mouse.up({ button: "right" });
await expect.poll(hash).not.toBe(before);
before = await hash();
await page.getByRole("button", { name: "Reset view" }).click();
await expect.poll(hash).not.toBe(before);
before = await hash();
await page.getByRole("button", { name: "Rotate left", exact: true }).click();
await page.getByRole("button", { name: "Zoom out", exact: true }).click();
await expect.poll(hash).not.toBe(before);
await page.screenshot({ path: test.info().outputPath(`${kind}.png`) });
expect(errors).toEqual([]);
});
}
test("missing and private files get useful errors, and retries recover", async ({ page }) => {
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ status: 404, json: { success: false } }));
await page.goto("/media/m_missing");
await expect(page.getByRole("heading", { name: "Media not found" })).toBeVisible();
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ status: 403, json: { success: false } }));
await page.getByRole("button", { name: "Try again" }).click();
await expect(page.getByRole("heading", { name: "This media is private" })).toBeVisible();
await mockMedia(page, fixture("image", "png"));
await page.getByRole("button", { name: "Try again" }).click();
await expect(page.getByAltText("Fixture image")).toBeVisible();
});
test("a network failure and malformed response do not strand the loading screen", async ({ page }) => {
await page.route(`${API}/v1/media_files/file/**`, (route) => route.abort());
await page.goto("/media/m_fixture_image");
await expect(page.getByRole("heading", { name: "Unable to load this media" })).toBeVisible();
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ json: { success: true } }));
await page.getByRole("button", { name: "Try again" }).click();
await expect(page.getByRole("heading", { name: "Unable to load this media" })).toBeVisible();
});
test("broken image offers the original, and unknown formats remain downloadable", async ({ page }) => {
await mockMedia(page, fixture("image", "png"));
await page.route(`${CDN}/**`, (route) => route.fulfill({ status: 404 }));
await page.goto("/media/m_fixture_image");
await expect(page.getByText("This browser could not display the file.")).toBeVisible();
await mockMedia(page, fixture("project", "scene_json"));
await page.goto("/media/m_fixture_project");
await expect(page.getByText(/A browser preview isn’t available/)).toBeVisible();
await expect(page.getByRole("button", { name: "Download file" })).toBeVisible();
});
test("download saves the original file, keeping existing CDN query parameters", async ({ page }) => {
await mockMedia(page, fixture("image", "png"));
await page.goto("/media/m_fixture_image");
const request = page.waitForRequest((r) => r.url().startsWith(CDN) && r.url().includes("dl=1"));
const downloaded = page.waitForEvent("download");
await page.getByRole("button", { name: "Download file" }).click();
expect((await downloaded).suggestedFilename()).toBe("m_fixture_image.png");
expect(new URL((await request).url()).searchParams.get("fixture")).toBe("1");
});
test("mobile layouts and both themes preserve the preview and actions", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockMedia(page, fixture("audio", "wav"));
await page.route(`${CDN}/asset.wav*`, (route) => serveBytes(route, wav(), "audio/wav"));
await page.goto("/media/m_fixture_audio");
await expect(page.getByRole("group", { name: "Audio player" })).toBeVisible();
await expect(page.getByRole("button", { name: "Download file" })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("mobile-light.png"), fullPage: true });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.getByRole("button", { name: "Switch to dark theme" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("body")).toHaveCSS("background-color", "rgb(18, 19, 22)");
await page.screenshot({ path: test.info().outputPath("mobile-dark.png"), fullPage: true });
});
test("legacy media types, PLY ambiguity, and safe CDN URLs", () => {
expect(mediaKind({ ...fixture("dimensional", "spz") })).toBe("splat");
expect(mediaKind({ ...fixture("dimensional", "glb") })).toBe("mesh");
expect(mediaKind(fixture("mesh", "ply"))).toBe("mesh");
expect(mediaKind(fixture("splat", "ply"))).toBe("splat");
expect(mediaKind(fixture("unknown", "mp3"))).toBe("audio");
expect(mediaKind(fixture("unknown", "webm"))).toBe("video");
expect(mediaKind({ ...fixture("unknown", "unknown"), media_links: { cdn_url: `${CDN}/IMAGE.PNG?x=.mp4` } })).toBe("image");
expect(corsMediaUrl(`${CDN}/scene.spz?token=test#preview`)).toBe(`${CDN}/scene.spz?token=test&cors=1#preview`);
expect(safeMediaUrl("javascript:alert(1)")).toBeUndefined();
});
function fixture(kind: string, format: string): SharedMedia {
return { token: `m_fixture_${kind}`, media_class: kind, media_type: format,
maybe_title: `Fixture ${kind}`, created_at: "2026-09-26T00:00:00Z",
maybe_creator_user: { username: "fixture_artist", display_name: "Fixture Artist" },
media_links: { cdn_url: `${CDN}/asset.${format}?fixture=1` } };
}
async function mockMedia(page: Page, media: SharedMedia) {
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ json: { success: true, media_file: media } }));
}
async function serveBytes(route: Route, bytes: Buffer, contentType: string) {
const range = route.request().headers().range?.match(/bytes=(\d+)-(\d*)/);
const start = range ? Number(range[1]) : 0;
const end = range?.[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1;
await route.fulfill({ status: range ? 206 : 200, contentType,
headers: { "accept-ranges": "bytes", "access-control-allow-origin": "*",
...(range ? { "content-range": `bytes ${start}-${end}/${bytes.length}` } : {}) },
body: bytes.subarray(start, end + 1) });
}
function glb() {
const positions = new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]);
let json = JSON.stringify({ asset: { version: "2.0" }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
materials: [{ doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [0.2, 0.5, 1, 1], metallicFactor: 0 } }],
buffers: [{ byteLength: positions.byteLength }], bufferViews: [{ buffer: 0, byteLength: positions.byteLength }],
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: "VEC3", min: [-1, -1, 0], max: [1, 1, 0] }] });
while (json.length % 4) json += " ";
const buffer = Buffer.alloc(28 + json.length + positions.byteLength);
buffer.writeUInt32LE(0x46546c67, 0); buffer.writeUInt32LE(2, 4); buffer.writeUInt32LE(buffer.length, 8);
buffer.writeUInt32LE(json.length, 12); buffer.writeUInt32LE(0x4e4f534a, 16); buffer.write(json, 20);
buffer.writeUInt32LE(positions.byteLength, 20 + json.length); buffer.writeUInt32LE(0x004e4942, 24 + json.length);
Buffer.from(positions.buffer).copy(buffer, 28 + json.length);
return buffer;
}
function splats() {
const buffer = Buffer.alloc(32 * 27);
for (let i = 0; i < 27; i++) {
const offset = i * 32;
[i % 3 - 1, Math.floor(i / 3) % 3 - 1, Math.floor(i / 9) - 1, 0.2, 0.2, 0.2].forEach((value, j) => buffer.writeFloatLE(value, offset + j * 4));
buffer.set([70, 160, 255, 255, 255, 128, 128, 128], offset + 24);
}
return buffer;
}
@@ -1,5 +1,6 @@
// Keep previous releases here when adding new downloads, newest first.
const DOWNLOAD_HISTORY = {
"0.41.0": githubDownloadLinks("0.41.0"),
"0.37.0": githubDownloadLinks("0.37.0"),
"0.32.0": githubDownloadLinks("0.32.0"),
"0.28.0": githubDownloadLinks("0.28.0"),
@@ -11,8 +12,8 @@ const DOWNLOAD_HISTORY = {
} as const;
// To roll back either platform, select a version from DOWNLOAD_HISTORY.
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
// Set false to temporarily hide desktop downloads throughout the website.
export const DOWNLOADS_ENABLED = true;
+1
View File
@@ -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 }));
}
}