Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 a8ef87a097 Track which app card or icon visitors pick on the Apps pages
Adds an app_select event to the /apps icon row, the lineup cards and the
"More from the family" sibling cards, so link_location shows which entry
point sent visitors to each app page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:51:34 +07:00
bflatasticandClaude Opus 5.5 084aa1b4b4 Add Google Analytics to the marketing site
Loads GA4 (gtag.js) when NEXT_PUBLIC_GA_MEASUREMENT_ID is set and sends
events for the site's conversion paths: desktop/Crafting App downloads,
webapp and CTA clicks, social and contact links, pricing checkout,
the H3 promptbox and auth gate, beta leads, video plays, and shares.

Most link clicks are classified by href in one delegated listener, so
components only change where an action isn't a plain link. Analytics
stays off when the env var is unset (local dev, deploy previews).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:34:44 +07:00
Brandon Thomas 03dea1c660 app downloads 2 (#49) 2026-10-05 06:38:56 -04:00
Brandon Thomas b5272188f7 App downloads (#48) 2026-10-05 06:17:51 -04:00
bflatastic b02f710c76 page scroll fix 2026-10-04 21:15:58 +07:00
BFlat 708de238c5 Add VectorCraft, EffectCraft, DesignCraft and app icons to Craft Apps pages (#47)
## Summary

Updates the Craft Apps pages to cover the org's seven Craft repos and
adds each app's icon.

- **VectorCraft replaces DrawCraft.** The repo was renamed, so the page
moves to `/apps/vectorcraft`. `/apps/drawcraft` permanently redirects
there, because every Craft README's app table still links the old URL.
- **New pages:** `/apps/effectcraft` (motion graphics & VFX) and
`/apps/designcraft` (page layout & publishing), each with copy,
screenshots and a share image.
- **App icons:**
  - A row in the `/apps` header linking to every app.
- On each lineup card and sibling-app card, overlapping the bottom of
the screenshot.
  - Above the wordmark in each app's hero.
- **PhotoCraft installers (`v0.1.1-rc.4`):**
  - A download button in the hero.
- macOS, Windows and Linux buttons, plus an "All builds and checksums"
link.
  - An "Installers ready" badge on its card.
  - `softwareVersion` and `downloadUrl` in its JSON-LD.
- **Share images:** each app's image has its icon in the corner, and the
`/apps` share image is the seven icons in a row.
- **Press kit:** a new "Crafting Apps icons" section, linking to each
repo's 1024px PNG.

## Details
- **Data file:** everything is still driven by
`src/lib/crafting-apps.ts`. An app's `release` field (version plus a
filename suffix per platform) produces its download buttons, so other
apps need one data entry when they ship.
- **Icons:** each repo's 512px render, converted to a 256px WebP with
transparency, about 25 KB each.
- **DesignCraft gallery:** its magazine pages are portrait, so they show
four across instead of in the 16:10 layout.
- **Hub copy:** the app count is worked out from the data ("Seven
apps"). The lineup is four columns wide on large screens, so seven cards
plus the Discord cell fill two even rows.
- **Colors:** EffectCraft and DesignCraft get their icon tile colors as
accents, with AA-contrast text colors in light and dark.
- **Adobe names:** the copy still never names Adobe products.

## Testing
- A clean `npm ci && npm run build` (what Netlify runs) passes with a
clean TypeScript check, and all seven app pages are generated as static
HTML.
- `next start` serves every page and asset with a 200, `/apps/drawcraft`
returns a 308 to `/apps/vectorcraft`, and an unknown slug returns 404.
- Checked visually at 1440px and at a 390px mobile viewport: no broken
images or horizontal overflow, and the mobile icon row wraps four over
three.
- The PhotoCraft download links and every 1024px icon URL in the press
kit resolve.
2026-10-02 23:06:56 +07:00
Brandon Thomas fc78787f30 better website hero text (#46) 2026-10-01 20:22:51 -04:00
BFlat e92253c5b4 Add Craft Apps marketing pages to the website (#45)
## Summary

Marketing pages on getartcraft.com for the five Craft Apps: PhotoCraft,
DrawCraft, FilmCraft, LightCraft and PrintCraft. Everything is driven by
one data file, `src/lib/crafting-apps.ts`.

- **`/apps` hub:** lineup cards for all five apps, shared principles,
and Discord calls to action.
- **`/apps/<slug>`:**
  - Hero screenshot with Discord, GitHub and share links.
  - Six highlights and a screenshot gallery.
- "Get it": a Discord waitlist for installers, next to build-from-source
steps with a copy button.
  - Cards linking to the sibling apps.
- **Nav:** a "Craft Apps" link after a divider following Support, and a
Discord button on desktop.
- **Footer:** a "Craft Apps" column.
- **Sitemap:** entries for the hub and all five app pages.
- **Press kit:** all 20 app screenshots, with full-resolution downloads.

## Branding
- **Colors:** each app has its own accent (`.app-*` in `globals.css`),
AA-contrast in light and dark.
- **Wordmarks:** text only, with "Craft" set in the app's color. These
pages don't use the serif accent font.
- **Share previews:** a 1200×630 image per app plus one for the hub, and
`SoftwareApplication` JSON-LD on each app page.
- **Adobe:** the copy never names Adobe products.

## Notes
- No installers exist yet in any of the five repos, so each page sends
visitors to Discord and to building from source. When an app ships a
release, adding `downloads` to its entry in `crafting-apps.ts` replaces
that block with download buttons.
- Screenshots come from each repo's `docs/images`, show only
public-domain artwork, and are converted to WebP at 1600px.
- **Small refactors:**
- New shared `DiscordButton`, now also used by the community strip and
the press kit.
  - New `CopyButton` in the UI kit.
  - `SITE_URL`/`siteUrl` moved into `lib/links.ts`.
  - New `kicker`/`mediaTag` props on `CampaignHero`.

## Testing
- A clean `npm ci && npm run build`, the same build Netlify runs,
passes: TypeScript is clean, `/apps` is static, and all five app pages
prerender as static HTML.
- `next start` serves all six routes plus their images, and an unknown
slug returns 404.
- Each page's Open Graph and Twitter tags render with the per-app image.
- Checked visually at 1440px and 1024px (the nav fits) and at a 390px
mobile viewport.
2026-10-01 22:56:48 +07:00
Brandon Thomas 847edc81e5 Merge pull request #44 from storytold/bt-no-more-gif-thumbnails
webp instead of gif thumbnails
2026-09-28 01:15:13 -04:00
Brandon Thomas 2e596cc4b3 ffmpeg fallback on mac 2026-09-28 01:07:45 -04:00
Brandon Thomas c3d72fb321 Generate WebP thumbnails for videos instead of Gif (except: MacOS 10.15)
MacOS Catalina 10.15 released October 7, 2019 can't do gifs in
safari/webkit.
2026-09-28 00:52:07 -04:00
Brandon Thomas cd27599c0a Merge pull request #43 from storytold/bt-additional-cors-tests
additional cors tests to prevent regressions
2026-09-27 23:43:50 -04:00
Brandon Thomas 46fd907182 additional cors tests to prevent regressions 2026-09-27 23:43:06 -04:00
Brandon Thomas 5bdc19f573 Merge pull request #42 from storytold/bt-domain-config-2
tests
2026-09-27 22:57:15 -04:00
Brandon Thomas 6b34f23b43 tests 2026-09-27 22:55:36 -04:00
Brandon Thomas ddf151794e Merge pull request #41 from storytold/bt-domain-config
domain config
2026-09-27 22:46:20 -04:00
Brandon Thomas 7a88118349 fold cdn configs in 2026-09-27 22:41:01 -04:00
Brandon Thomas e07bf35cd0 static config for first party domains 2026-09-27 22:40:14 -04:00
Brandon Thomas a1147a98d2 Merge pull request #40 from storytold/bt-desktop-site
add desktop marketing website
2026-09-27 22:12:11 -04:00
Brandon Thomas f67fb7db31 add desktop marketing website 2026-09-27 22:10:27 -04:00
Brandon Thomas 7d4e89ced9 Merge pull request #39 from storytold/bt-query-perf-2
fixes to new endpoint
2026-09-27 22:01:25 -04:00
Brandon Thomas b9ee1bd225 fixes to new endpoint 2026-09-27 22:01:01 -04:00
Brandon Thomas 68886b62fe Merge pull request #37 from storytold/bt-query-perf
more performant list user files endpoint
2026-09-27 20:22:51 -04:00
Brandon Thomas 293a41c7d4 let's actually not call the new endpoint prematurely 2026-09-27 17:35:00 -04:00
Brandon Thomas 35a859529f Merge pull request #38 from storytold/bt-tauri-cors
cors for tauri localhost + tauri desktop
2026-09-27 17:28:25 -04:00
Brandon Thomas 3a93e358cd tests 2026-09-27 17:27:40 -04:00
Brandon Thomas 1cb0b32898 move desktop configs 2026-09-27 17:25:20 -04:00
Brandon Thomas c50bb78c32 tauri cors for new domain 2026-09-27 17:12:27 -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
260 changed files with 8428 additions and 1111 deletions

No files matched your search

@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -75,6 +75,16 @@
},
{
"ordinal": 7,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 8,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -84,7 +94,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -212,6 +222,7 @@
true,
true,
true,
true,
false,
true,
true,
@@ -219,5 +230,5 @@
false
]
},
"hash": "a52cc2d6e86607c430173553230b3794a4c1ada07274a975c04128e47d3b7f83"
"hash": "251082fa59374723719147af19e3411300291f1c7de0acffd078dff73f7fcf40"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nWHERE\n m.maybe_batch_token = ?\n ",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nWHERE\n m.maybe_batch_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n ",
"describe": {
"columns": [
{
@@ -255,6 +255,16 @@
},
{
"ordinal": 25,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 26,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -264,7 +274,7 @@
}
},
{
"ordinal": 26,
"ordinal": 27,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -303,9 +313,10 @@
false,
true,
true,
true,
false,
false
]
},
"hash": "971471f349bcd22364b16b5d6c68e807ed897101eec1e8a62a7b8fab3359fa0c"
"hash": "29f205860e3a790cc0b4b4b8bbd28bbc92a9df27ba62cc2688be1aa195b1fcf2"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n pci.media_token as `media_token: tokens::tokens::media_files::MediaFileToken`,\n pci.context_semantic_type as `context_semantic_type: enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension\n\nFROM prompt_context_items pci\nJOIN media_files m\nON\n pci.media_token = m.token\nWHERE\n pci.prompt_token = ?\nORDER BY pci.id ASC\n ",
"query": "\nSELECT\n pci.media_token as `media_token: tokens::tokens::media_files::MediaFileToken`,\n pci.context_semantic_type as `context_semantic_type: enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n m.created_at as `created_at: DateTime<Utc>`\n\nFROM prompt_context_items pci\nJOIN media_files m\nON\n pci.media_token = m.token\nWHERE\n pci.prompt_token = ?\nORDER BY pci.id ASC\n ",
"describe": {
"columns": [
{
@@ -52,6 +52,26 @@
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 6,
"name": "created_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
@@ -62,8 +82,10 @@
false,
false,
true,
true
true,
true,
false
]
},
"hash": "094eb099cde85835db0662c2700c6b82644b51bf9b3609391ed4d2945fe5e98f"
"hash": "2e53d51429bf380b141a8a680d1fc17990ab02f822cae4d8def2d1126e513549"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -95,6 +95,16 @@
},
{
"ordinal": 9,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "is_intermediate_system_file: bool",
"type_info": {
"type": "Tiny",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -214,7 +224,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -224,7 +234,7 @@
}
},
{
"ordinal": 22,
"ordinal": 23,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -250,6 +260,7 @@
true,
true,
true,
true,
false,
false,
false,
@@ -263,5 +274,5 @@
false
]
},
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
"hash": "41b47b3e0874676651bd7de2c6c3bd2c25db56c51ce6e16810f4a7f56dfb48ab"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n media_files.maybe_thumbnail_version as `maybe_media_file_thumbnail_version?: u8`,\n media_files.created_at as `maybe_media_file_created_at?: DateTime<Utc>`,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
@@ -265,6 +265,26 @@
},
{
"ordinal": 26,
"name": "maybe_media_file_thumbnail_version?: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 27,
"name": "maybe_media_file_created_at?: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 28,
"name": "maybe_assigned_worker",
"type_info": {
"type": "VarString",
@@ -274,7 +294,7 @@
}
},
{
"ordinal": 27,
"ordinal": 29,
"name": "maybe_assigned_cluster",
"type_info": {
"type": "VarString",
@@ -284,7 +304,7 @@
}
},
{
"ordinal": 28,
"ordinal": 30,
"name": "is_keepalive_required",
"type_info": {
"type": "Tiny",
@@ -294,7 +314,7 @@
}
},
{
"ordinal": 29,
"ordinal": 31,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -304,7 +324,7 @@
}
},
{
"ordinal": 30,
"ordinal": 32,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -314,7 +334,7 @@
}
},
{
"ordinal": 31,
"ordinal": 33,
"name": "maybe_first_started_at",
"type_info": {
"type": "Timestamp",
@@ -324,7 +344,7 @@
}
},
{
"ordinal": 32,
"ordinal": 34,
"name": "maybe_successfully_completed_at",
"type_info": {
"type": "Timestamp",
@@ -334,7 +354,7 @@
}
},
{
"ordinal": 33,
"ordinal": 35,
"name": "database_clock",
"type_info": {
"type": "Datetime",
@@ -376,6 +396,8 @@
true,
true,
true,
true,
true,
false,
false,
false,
@@ -384,5 +406,5 @@
false
]
},
"hash": "6948edca3f06643605695665df51cc94f897e2cb9a2d6906910d58d4d56ee73e"
"hash": "4b3fa0dcfb916ba7af04ae35c69a2b75d1f4a4858e8f94f87d74c69fccc32079"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -213,11 +223,12 @@
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "d8f4ce4e1026c71a7be8fd87f00f6f8858c401701da1ca3316eed1ed971d4bb2"
"hash": "503c8faa461462b3e2f8cae3cb702922ad54a8ddbdb01d1e7fc785ca1b7dd4c4"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.maybe_project_type = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.maybe_project_type = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -213,11 +223,12 @@
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "11cc7b91730784e9e8c8f073aeaf51a1f1660584e379b9a5be3aea370d84f692"
"hash": "770f5c0672653e82c40deade6ba7b1e769558d8ebcf40d736f798f30c7a75c68"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -171,9 +181,10 @@
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
"hash": "79f08e53fdacb526524810f481d22ca169b9f35e8874ed898b63cd2ba4b7453d"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_engine_category as `maybe_engine_category: enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory`,\n m.maybe_animation_type as `maybe_animation_type: enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_style_transfer_source_media_file_token as `maybe_style_transfer_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n m.maybe_scene_source_media_file_token as `maybe_scene_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n model_weights.token as `maybe_model_weights_token: tokens::tokens::model_weights::ModelWeightToken`,\n model_weights.title as maybe_model_weights_title,\n model_weights.weights_type as `maybe_model_weights_type: enums::by_table::model_weights::weights_types::WeightsType`,\n model_weights.weights_category as `maybe_model_weights_category: enums::by_table::model_weights::weights_category::WeightsCategory`,\n\n model_weight_cover_image.public_bucket_directory_hash as maybe_model_cover_image_public_bucket_hash,\n model_weight_cover_image.maybe_public_bucket_prefix as maybe_model_cover_image_public_bucket_prefix,\n model_weight_cover_image.maybe_public_bucket_extension as maybe_model_cover_image_public_bucket_extension,\n\n model_weight_creator.token as `maybe_model_weight_creator_user_token: tokens::tokens::users::UserToken`,\n model_weight_creator.username as maybe_model_weight_creator_username,\n model_weight_creator.display_name as maybe_model_weight_creator_display_name,\n model_weight_creator.email_gravatar_hash as maybe_model_weight_creator_gravatar_hash,\n\n m.extra_file_modification_info,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n entity_stats.ratings_positive_count as maybe_ratings_positive_count,\n entity_stats.ratings_negative_count as maybe_ratings_negative_count,\n entity_stats.bookmark_count as maybe_bookmark_count,\n\n featured_items.entity_token IS NOT NULL AS is_featured,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN model_weights\n ON m.maybe_origin_model_token = model_weights.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN media_files as model_weight_cover_image\n ON model_weight_cover_image.token = model_weights.maybe_cover_image_media_file_token\nLEFT OUTER JOIN users as model_weight_creator\n ON model_weight_creator.token = model_weights.creator_user_token\nLEFT OUTER JOIN entity_stats\n ON entity_stats.entity_type = \"media_file\"\n AND entity_stats.entity_token = m.token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nLEFT OUTER JOIN featured_items\n ON featured_items.entity_type = 'media_file'\n AND featured_items.entity_token = m.token\n AND featured_items.deleted_at IS NULL\nWHERE\n m.token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n ",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_engine_category as `maybe_engine_category: enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory`,\n m.maybe_animation_type as `maybe_animation_type: enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_style_transfer_source_media_file_token as `maybe_style_transfer_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n m.maybe_scene_source_media_file_token as `maybe_scene_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n model_weights.token as `maybe_model_weights_token: tokens::tokens::model_weights::ModelWeightToken`,\n model_weights.title as maybe_model_weights_title,\n model_weights.weights_type as `maybe_model_weights_type: enums::by_table::model_weights::weights_types::WeightsType`,\n model_weights.weights_category as `maybe_model_weights_category: enums::by_table::model_weights::weights_category::WeightsCategory`,\n\n model_weight_cover_image.public_bucket_directory_hash as maybe_model_cover_image_public_bucket_hash,\n model_weight_cover_image.maybe_public_bucket_prefix as maybe_model_cover_image_public_bucket_prefix,\n model_weight_cover_image.maybe_public_bucket_extension as maybe_model_cover_image_public_bucket_extension,\n\n model_weight_creator.token as `maybe_model_weight_creator_user_token: tokens::tokens::users::UserToken`,\n model_weight_creator.username as maybe_model_weight_creator_username,\n model_weight_creator.display_name as maybe_model_weight_creator_display_name,\n model_weight_creator.email_gravatar_hash as maybe_model_weight_creator_gravatar_hash,\n\n m.extra_file_modification_info,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n entity_stats.ratings_positive_count as maybe_ratings_positive_count,\n entity_stats.ratings_negative_count as maybe_ratings_negative_count,\n entity_stats.bookmark_count as maybe_bookmark_count,\n\n featured_items.entity_token IS NOT NULL AS is_featured,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN model_weights\n ON m.maybe_origin_model_token = model_weights.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN media_files as model_weight_cover_image\n ON model_weight_cover_image.token = model_weights.maybe_cover_image_media_file_token\nLEFT OUTER JOIN users as model_weight_creator\n ON model_weight_creator.token = model_weights.creator_user_token\nLEFT OUTER JOIN entity_stats\n ON entity_stats.entity_type = \"media_file\"\n AND entity_stats.entity_token = m.token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nLEFT OUTER JOIN featured_items\n ON featured_items.entity_type = 'media_file'\n AND featured_items.entity_token = m.token\n AND featured_items.deleted_at IS NULL\nWHERE\n m.token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n ",
"describe": {
"columns": [
{
@@ -415,6 +415,16 @@
},
{
"ordinal": 41,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 42,
"name": "maybe_ratings_positive_count",
"type_info": {
"type": "Long",
@@ -424,7 +434,7 @@
}
},
{
"ordinal": 42,
"ordinal": 43,
"name": "maybe_ratings_negative_count",
"type_info": {
"type": "Long",
@@ -434,7 +444,7 @@
}
},
{
"ordinal": 43,
"ordinal": 44,
"name": "maybe_bookmark_count",
"type_info": {
"type": "Long",
@@ -444,7 +454,7 @@
}
},
{
"ordinal": 44,
"ordinal": 45,
"name": "is_featured",
"type_info": {
"type": "LongLong",
@@ -454,7 +464,7 @@
}
},
{
"ordinal": 45,
"ordinal": 46,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -464,7 +474,7 @@
}
},
{
"ordinal": 46,
"ordinal": 47,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -522,10 +532,11 @@
true,
true,
true,
true,
false,
false,
false
]
},
"hash": "ad217c26087b2ebed6f2c9f79b16ead63727107dfce3366f3c1d12bc9d565c2c"
"hash": "7a499a8fabb4633ef1c26a2df29851a706d9b6d5e99d2dc57a42f3939c7c5ecd"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.media_class = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.media_class = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -229,6 +239,7 @@
true,
true,
true,
true,
false,
false,
true,
@@ -241,5 +252,5 @@
false
]
},
"hash": "3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c"
"hash": "96d03f60d11b2303a8fe8ac16e17cd1ee58fec404049ee3f906ed7b5fcc9c3ed"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -95,6 +95,16 @@
},
{
"ordinal": 9,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "is_intermediate_system_file: bool",
"type_info": {
"type": "Tiny",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -214,7 +224,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -224,7 +234,7 @@
}
},
{
"ordinal": 22,
"ordinal": 23,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -250,6 +260,7 @@
true,
true,
true,
true,
false,
false,
false,
@@ -263,5 +274,5 @@
false
]
},
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
"hash": "9bf5ccbfaaa7c1c4e9c4af4b3044b307976825383c9a737a22a877c89cd6db09"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.maybe_project_type = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.maybe_project_type = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -213,11 +223,12 @@
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "6c7adbc8171bc3fdfb6109193ffb0a55413fa0942746d65037c2b1d39b361bd0"
"hash": "9c8c0ea8510cd66175014e4cf4ffaf403c27256ae1e39f06f1bc430dbcea5e62"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -229,6 +239,7 @@
true,
true,
true,
true,
false,
false,
true,
@@ -241,5 +252,5 @@
false
]
},
"hash": "aa1e693babe6997e75935ec893e6eed46351ec72ce02a9144fd11d2fcfd37fdf"
"hash": "9f116e00964272b8389a766674177e7882a16ad6459785551bc2093e27404f80"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -75,6 +75,16 @@
},
{
"ordinal": 7,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 8,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -84,7 +94,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -212,6 +222,7 @@
true,
true,
true,
true,
false,
true,
true,
@@ -219,5 +230,5 @@
false
]
},
"hash": "73540435892b7b4dbc11c6c63712abdb18fd8b3db2bcd56b8518c968b8169de7"
"hash": "b0d2f025aa7d137c0a9f50d0af4bf863e1d0741d9a34111bdfaaf18f10ec11f4"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n m.id as `id: u64`,\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.maybe_project_type as `project_type!: enums::by_table::media_files::media_file_project_type::MediaFileProjectType`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_creator_user_token = ?\n AND m.media_class = 'project'\n AND m.maybe_project_type IS NOT NULL\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -213,11 +223,12 @@
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "97e4bc3ab037126e3f4e213cdb7535c2ad233809fbeb6607c34fb02ec45cfe47"
"hash": "d96bca438343b0a5a3f202ae48b51670ed3e660cdfaa0100b47893249d5d0c11"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -229,6 +239,7 @@
true,
true,
true,
true,
false,
false,
true,
@@ -241,5 +252,5 @@
false
]
},
"hash": "76bcf698f40d852558f071a6909b05b1c960c1cf236831ae009f90de46d3aefb"
"hash": "dabd15fbe5fd3cc9f0ce921d9c804ea25956fc36bb7e677d29b869b31f2c6c1d"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_engine_category as `maybe_engine_category: enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory`,\n m.maybe_animation_type as `maybe_animation_type: enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_style_transfer_source_media_file_token as `maybe_style_transfer_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n m.maybe_scene_source_media_file_token as `maybe_scene_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n model_weights.token as `maybe_model_weights_token: tokens::tokens::model_weights::ModelWeightToken`,\n model_weights.title as maybe_model_weights_title,\n model_weights.weights_type as `maybe_model_weights_type: enums::by_table::model_weights::weights_types::WeightsType`,\n model_weights.weights_category as `maybe_model_weights_category: enums::by_table::model_weights::weights_category::WeightsCategory`,\n\n model_weight_cover_image.public_bucket_directory_hash as maybe_model_cover_image_public_bucket_hash,\n model_weight_cover_image.maybe_public_bucket_prefix as maybe_model_cover_image_public_bucket_prefix,\n model_weight_cover_image.maybe_public_bucket_extension as maybe_model_cover_image_public_bucket_extension,\n\n model_weight_creator.token as `maybe_model_weight_creator_user_token: tokens::tokens::users::UserToken`,\n model_weight_creator.username as maybe_model_weight_creator_username,\n model_weight_creator.display_name as maybe_model_weight_creator_display_name,\n model_weight_creator.email_gravatar_hash as maybe_model_weight_creator_gravatar_hash,\n\n m.extra_file_modification_info,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n entity_stats.ratings_positive_count as maybe_ratings_positive_count,\n entity_stats.ratings_negative_count as maybe_ratings_negative_count,\n entity_stats.bookmark_count as maybe_bookmark_count,\n\n featured_items.entity_token IS NOT NULL AS is_featured,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN model_weights\n ON m.maybe_origin_model_token = model_weights.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN media_files as model_weight_cover_image\n ON model_weight_cover_image.token = model_weights.maybe_cover_image_media_file_token\nLEFT OUTER JOIN users as model_weight_creator\n ON model_weight_creator.token = model_weights.creator_user_token\nLEFT OUTER JOIN entity_stats\n ON entity_stats.entity_type = \"media_file\"\n AND entity_stats.entity_token = m.token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nLEFT OUTER JOIN featured_items\n ON featured_items.entity_type = 'media_file'\n AND featured_items.entity_token = m.token\n AND featured_items.deleted_at IS NULL\nWHERE\n m.token = ?\n ",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_engine_category as `maybe_engine_category: enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory`,\n m.maybe_animation_type as `maybe_animation_type: enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_style_transfer_source_media_file_token as `maybe_style_transfer_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n m.maybe_scene_source_media_file_token as `maybe_scene_source_media_file_token: tokens::tokens::media_files::MediaFileToken`,\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n model_weights.token as `maybe_model_weights_token: tokens::tokens::model_weights::ModelWeightToken`,\n model_weights.title as maybe_model_weights_title,\n model_weights.weights_type as `maybe_model_weights_type: enums::by_table::model_weights::weights_types::WeightsType`,\n model_weights.weights_category as `maybe_model_weights_category: enums::by_table::model_weights::weights_category::WeightsCategory`,\n\n model_weight_cover_image.public_bucket_directory_hash as maybe_model_cover_image_public_bucket_hash,\n model_weight_cover_image.maybe_public_bucket_prefix as maybe_model_cover_image_public_bucket_prefix,\n model_weight_cover_image.maybe_public_bucket_extension as maybe_model_cover_image_public_bucket_extension,\n\n model_weight_creator.token as `maybe_model_weight_creator_user_token: tokens::tokens::users::UserToken`,\n model_weight_creator.username as maybe_model_weight_creator_username,\n model_weight_creator.display_name as maybe_model_weight_creator_display_name,\n model_weight_creator.email_gravatar_hash as maybe_model_weight_creator_gravatar_hash,\n\n m.extra_file_modification_info,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n entity_stats.ratings_positive_count as maybe_ratings_positive_count,\n entity_stats.ratings_negative_count as maybe_ratings_negative_count,\n entity_stats.bookmark_count as maybe_bookmark_count,\n\n featured_items.entity_token IS NOT NULL AS is_featured,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN model_weights\n ON m.maybe_origin_model_token = model_weights.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN media_files as model_weight_cover_image\n ON model_weight_cover_image.token = model_weights.maybe_cover_image_media_file_token\nLEFT OUTER JOIN users as model_weight_creator\n ON model_weight_creator.token = model_weights.creator_user_token\nLEFT OUTER JOIN entity_stats\n ON entity_stats.entity_type = \"media_file\"\n AND entity_stats.entity_token = m.token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nLEFT OUTER JOIN featured_items\n ON featured_items.entity_type = 'media_file'\n AND featured_items.entity_token = m.token\n AND featured_items.deleted_at IS NULL\nWHERE\n m.token = ?\n ",
"describe": {
"columns": [
{
@@ -415,6 +415,16 @@
},
{
"ordinal": 41,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 42,
"name": "maybe_ratings_positive_count",
"type_info": {
"type": "Long",
@@ -424,7 +434,7 @@
}
},
{
"ordinal": 42,
"ordinal": 43,
"name": "maybe_ratings_negative_count",
"type_info": {
"type": "Long",
@@ -434,7 +444,7 @@
}
},
{
"ordinal": 43,
"ordinal": 44,
"name": "maybe_bookmark_count",
"type_info": {
"type": "Long",
@@ -444,7 +454,7 @@
}
},
{
"ordinal": 44,
"ordinal": 45,
"name": "is_featured",
"type_info": {
"type": "LongLong",
@@ -454,7 +464,7 @@
}
},
{
"ordinal": 45,
"ordinal": 46,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -464,7 +474,7 @@
}
},
{
"ordinal": 46,
"ordinal": 47,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -522,10 +532,11 @@
true,
true,
true,
true,
false,
false,
false
]
},
"hash": "784ca68c6cb61d481b605bfe6ee3248a5b44499038c71b57fc9ea6c4722d170f"
"hash": "e6c3c16d96339ad84b65302f3610b4b798e7ea16c2ae9d03d6b706cb42989990"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nWHERE\n m.maybe_batch_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n ",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_mime_type,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\n\n m.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n\n m.maybe_title,\n m.maybe_text_transcript,\n\n m.maybe_origin_filename,\n\n m.maybe_duration_millis,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n prompts.maybe_other_args as maybe_other_prompt_args,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.is_user_upload,\n m.is_intermediate_system_file,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nLEFT OUTER JOIN prompts\n ON prompts.token = m.maybe_prompt_token\nWHERE\n m.maybe_batch_token = ?\n ",
"describe": {
"columns": [
{
@@ -255,6 +255,16 @@
},
{
"ordinal": 25,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 26,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -264,7 +274,7 @@
}
},
{
"ordinal": 26,
"ordinal": 27,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -303,9 +313,10 @@
false,
true,
true,
true,
false,
false
]
},
"hash": "f75fedb86a93ac2542315ddb7ef0d46aa42ecc25a3cf9856219114064183337a"
"hash": "ede1fc9893cb4309a89acc15a567b660faba1bb7eb0bf012605a9dfa15a53229"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -229,6 +239,7 @@
true,
true,
true,
true,
false,
false,
true,
@@ -241,5 +252,5 @@
false
]
},
"hash": "6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a"
"hash": "f392e28b5f4287625ccb1ee0668089f9049940368da50b7f0e0b8370f112305c"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_file_tags mft\nJOIN media_files mf\n ON mf.token = mft.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mft.tag_token = ?\n AND mft.user_token = ?\n AND mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_file_tags mft\nJOIN media_files mf\n ON mf.token = mft.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mft.tag_token = ?\n AND mft.user_token = ?\n AND mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
@@ -104,7 +114,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
@@ -114,7 +124,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
@@ -124,7 +134,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
@@ -134,7 +144,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "maybe_title",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -229,6 +239,7 @@
true,
true,
true,
true,
false,
false,
true,
@@ -241,5 +252,5 @@
false
]
},
"hash": "f36c7238231353882b5b2ac39616dcdc6adebf7c7e1198d9c2af4674ba5d982c"
"hash": "fbdbb627018a7664499834149d5f571457ba4313b1666152197cdbea92c8068c"
}
+13
View File
@@ -35,6 +35,7 @@ artcraft-services/
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (Vite)
│ └── libs/ # Shared React components and TypeScript libraries
└── Cargo.toml # Rust workspace
```
@@ -96,6 +97,18 @@ Organize for top-to-bottom reading. Important things first, details later.
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
## Public API Compatibility
- Public HTTP endpoints have deployed legacy clients. Preserve their request and
response schemas, defaults, limits, and pagination behavior when optimizing them.
- Put a changed contract behind a new endpoint/version and update new clients to
call it explicitly. Do not add a new mode or change response fields on an existing
public endpoint as part of a performance fix.
- For an endpoint-local revision, version the operation path (for example,
`/v1/media_files/list_v2/user/{username}`). Reserve a top-level `/v2` for broader
API changes.
- Add regression tests for the original contract alongside tests for the new route.
## Markdown
- **Tables must be space-padded so columns align in plain text.** Markdown
Generated
+9
View File
@@ -313,6 +313,7 @@ dependencies = [
"actix-http",
"actix-rt",
"actix-web",
"domains",
"log",
"server_environment",
"speculoos",
@@ -2688,6 +2689,13 @@ dependencies = [
"litrs",
]
[[package]]
name = "domains"
version = "0.0.1"
dependencies = [
"url",
]
[[package]]
name = "dotenv"
version = "0.15.0"
@@ -8715,6 +8723,7 @@ dependencies = [
"datetimes",
"derive_more 0.99.17",
"diesel",
"domains",
"easyenv",
"elasticsearch",
"elasticsearch_schema",
+2
View File
@@ -67,6 +67,7 @@ members = [
"crates/lib/files/zip_archives",
"crates/lib/http_headers",
"crates/lib/jobs_common",
"crates/lib/domains",
"crates/lib/jwt_light",
"crates/lib/opaque_cursors",
"crates/lib/jwt_signer",
@@ -252,6 +253,7 @@ grok_api_client = { path = "crates/api_clients/grok_api_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
rootly_config = { path = "crates/schema/config/rootly_config" }
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
domains = { path = "crates/lib/domains" }
http_headers = { path = "crates/lib/http_headers" }
images = { path = "crates/lib/files/images" }
jobs_common = { path = "crates/lib/jobs_common" }
@@ -0,0 +1,30 @@
use serde_derive::{Deserialize, Serialize};
use url::Url;
use utoipa::ToSchema;
use crate::common::responses::cover_image_links::CoverImageLinks;
use crate::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
// User-media list endpoints retain the deprecated bucket fields for compatibility.
/// Everything we need to create a cover image.
/// Cover images are small descriptive images that can be set for any media file.
/// If a cover image is set, this is the path to the asset.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct LegacyMediaFileCoverImageDetails {
/// (DEPRECATED) URL path to the media file
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
pub maybe_cover_image_public_bucket_path: Option<String>,
/// (DEPRECATED) Full URL to the media file
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
pub maybe_cover_image_public_bucket_url: Option<Url>,
/// Links to the cover image (CDN direct link, thumbnail template)
/// If a cover image is set, this is the path to the asset.
/// If a cover image is not set, use the information in `default_cover` instead.
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub maybe_links: Option<CoverImageLinks>,
/// For items without a cover image, we can use one of our own.
pub default_cover: MediaFileDefaultCover,
}
@@ -0,0 +1,44 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use tokens::tokens::model_weights::ModelWeightToken;
/// Fields useful for enriching media file listings
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaFileOriginDetails {
/// Where the file came from (broadly)
pub origin_category: MediaFileOriginCategory,
/// Where the file came from (specifically, a product area, eg. "face_animator")
pub product_category: MediaFileOriginProductCategory,
/// If the file was produced by a model, the details.
pub maybe_model: Option<MediaFileModelDetails>,
}
/// Information about the model weights
/// https://serde.rs/enum-representations.html#untagged
#[derive(Clone, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum MediaFileModelDetails {
// Try the specific shape first so deserialization retains token and title.
/// A model weight is typically a user-submitted model.
/// We store lots of these.
ModelWeight {
/// The type of model weight
model_type: MediaFileOriginModelType,
/// The model token
token: ModelWeightToken,
/// The model title (typically only populated for `model_weights` models, not legacy tables such as `tts_models`.)
title: String,
},
/// A system model is one that we maintain and update,
/// eg. SadTalker, Wav2Lip, MocapNet, etc.
SystemModel {
/// The type of model weight
model_type: MediaFileOriginModelType,
},
}
@@ -15,7 +15,8 @@ pub struct MediaLinks {
/// video previews, which, in turn, have their own thumbnail templates.)
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// Video preview images (still, animated webp or gif, and a Catalina gif
/// fallback) for mp4 video files.
/// These are only set for video media files.
/// These are not set for "cover images" that are gif or webms, but rather video files.
pub maybe_video_previews: Option<VideoPreviews>,
@@ -26,7 +27,9 @@ pub struct VideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
/// An animated preview of the video: an animated webp for videos with
/// thumbnail version 2 (2026-09+), otherwise a gif. Clients that can't
/// render animated webp should use `maybe_catalina_fallback_animated_gif`.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
@@ -36,4 +39,21 @@ pub struct VideoPreviews {
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
/// An animated gif preview of the video, for clients that can't render the
/// webp in `animated`: ArtCraft desktop on macOS 10.15 (Catalina) runs on a
/// WKWebView without animated webp support. Since thumbnail version 2
/// (2026-09) the thumbnail job writes this gif beside the webp; for older
/// videos it's the same file as `animated`. See `video_thumbnail_suffixes`
/// in `bucket_paths` for the version history.
///
/// Always set by the server. Optional (and defaulted) only so newer clients
/// can still read responses from older servers.
#[serde(default)]
pub maybe_catalina_fallback_animated_gif: Option<Url>,
/// A template used to construct the Catalina fallback gif thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
#[serde(default)]
pub maybe_catalina_fallback_animated_gif_thumbnail_template: Option<String>,
}
@@ -1,8 +1,10 @@
pub mod cover_image_links;
pub mod job_details;
pub mod legacy_media_file_cover_image_details;
pub mod media_file_cover_image_details;
pub mod media_file_origin_details;
pub mod media_links;
pub mod pagination_cursors;
pub mod simple_entity_stats;
pub mod simple_generic_json_success;
pub mod user_details_light;
pub mod user_details_light;
@@ -1,9 +1,10 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
// TODO(bt,2025-08-20): Replace the storyteller-web version of this.
/// Simple stats that can be attached to any entity
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct SimpleEntityStats {
/// Number of positive ratings (or "likes") for this item
pub positive_rating_count: u32,
@@ -68,7 +68,8 @@ pub struct FolderThumbnail {
/// which, in turn, have their own thumbnail templates.)
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// Video preview images (still, animated webp or gif, and a Catalina gif
/// fallback) for mp4 video files.
/// These are only set for video media files.
pub maybe_video_previews: Option<FolderThumbnailVideoPreviews>,
}
@@ -81,7 +82,9 @@ pub struct FolderThumbnailVideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
/// An animated preview of the video: an animated webp for videos with
/// thumbnail version 2 (2026-09+), otherwise a gif. Clients that can't
/// render animated webp should use `maybe_catalina_fallback_animated_gif`.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
@@ -91,4 +94,21 @@ pub struct FolderThumbnailVideoPreviews {
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
/// An animated gif preview of the video, for clients that can't render the
/// webp in `animated`: ArtCraft desktop on macOS 10.15 (Catalina) runs on a
/// WKWebView without animated webp support. Since thumbnail version 2
/// (2026-09) the thumbnail job writes this gif beside the webp; for older
/// videos it's the same file as `animated`. See `video_thumbnail_suffixes`
/// in `bucket_paths` for the version history.
///
/// Always set by the server. Optional (and defaulted) only so newer clients
/// can still read responses from older servers.
#[serde(default)]
pub maybe_catalina_fallback_animated_gif: Option<Url>,
/// A template used to construct the Catalina fallback gif thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
#[serde(default)]
pub maybe_catalina_fallback_animated_gif_thumbnail_template: Option<String>,
}
@@ -0,0 +1,67 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
pub const LIST_MEDIA_FILES_FOR_USER_V2_URL_PATH: &str = "/v1/media_files/list_v2/user/{username}";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesForUserV2PathInfo {
pub username: String,
}
#[derive(Serialize, Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesForUserV2QueryParams {
pub sort_ascending: Option<bool>,
pub page_size: Option<usize>,
pub page_index: Option<usize>,
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
/// which are the broad category of media files: image, video, etc.
///
/// Usage:
/// - `?filter_media_classes=audio`
/// - `?filter_media_classes=image,video`
/// - `?filter_media_classes=dimensional`
/// - etc.
pub filter_media_classes: Option<String>,
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
/// which are mimetype-like / format-like categories of media files: glb, gltf,
/// scene_json, jpg, png, mp4, wav, etc.
///
/// Usage:
/// - `?filter_media_type=scene_json`
/// - `?filter_media_type=glb,gltf,fbx`
/// - `?filter_media_type=pmd,vmd,pmx`
/// - `?filter_media_type=jpg,png,gif`
/// - `?filter_media_type=wav,mp3`
/// - `?filter_media_type=mp4`
/// - etc.
pub filter_media_type: Option<String>,
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
///
/// Usage:
/// - `?filter_engine_categories=scene`
/// - `?filter_engine_categories=animation,character,object`
/// - etc.
pub filter_engine_categories: Option<String>,
/// Include user uploaded files in the results.
/// By default, we do not return them unless this flag is set to true.
pub include_user_uploads: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesForUserV2SuccessResponse {
pub success: bool,
pub results: Vec<MediaFileForUserListItem>,
pub pagination: UserMediaFileV2Pagination,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UserMediaFileV2Pagination {
pub current: usize,
pub has_more: bool,
}
@@ -1 +1,3 @@
pub mod by_type;
pub mod list_media_files_for_user_v2;
pub mod user_media_file_list_item;
@@ -0,0 +1,106 @@
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::ToSchema;
use crate::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
use crate::common::responses::media_file_origin_details::MediaFileOriginDetails;
use crate::common::responses::media_links::MediaLinks;
use crate::common::responses::simple_entity_stats::SimpleEntityStats;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MediaFileForUserListItem {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If this is an engine/3D asset, this is the broad category (scene,
/// animation, etc.) of that object.
/// This can also be used for filtering in list/batch endpoints.
pub maybe_engine_category: Option<MediaFileEngineCategory>,
/// If this is an engine/3D asset for an animation or a rig that can
/// be animated with either (or both) skeletal or blend shape animations,
/// this describes the animation regime used or supported.
pub maybe_animation_type: Option<MediaFileAnimationType>,
/// Details where the media file came from.
pub origin: MediaFileOriginDetails,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub origin_category: MediaFileOriginCategory,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub origin_product_category: MediaFileOriginProductCategory,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub maybe_origin_model_token: Option<String>,
/// (DEPRECATED) URL path to the media file
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
pub public_bucket_path: String,
/// (DEPRECATED) Full URL to the media file
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
pub public_bucket_url: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: LegacyMediaFileCoverImageDetails,
pub creator_set_visibility: Visibility,
/// The file was uploaded by the user.
/// This does not include files generated on the client side, like studio renders.
pub is_user_upload: bool,
/// The file was created by the system.
/// This includes files generated on the client side, like studio renders.
pub is_intermediate_system_file: bool,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
/// For Comfy / Video Style Transfer jobs, this might include
/// the name of the selected style.
pub maybe_style_name: Option<StyleTransferName>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
/// Statistics about the media file
pub stats: SimpleEntityStats,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -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?;
}
+1
View File
@@ -20,6 +20,7 @@ normal = ["workspace-build-acceleration"]
workspace-build-acceleration = { workspace = true }
# Internal
domains.workspace = true
server_environment.workspace = true
# External
@@ -0,0 +1,328 @@
//! Regression tests pinning every exact origin the CORS config allowed before
//! first-party domains moved to the `domains` crate (commit a1147a98d2).
//!
//! The origins are string literals on purpose: they check the `domains`
//! constants against the real values instead of against themselves. Each row
//! is checked against its module in isolation (so a module can't pass because
//! another module allows the same origin) and against the full config.
//!
//! The table was generated from the `allowed_origin("...")` calls at
//! a1147a98d2. When you add or remove an origin, update this table too.
use actix_cors::Cors;
use server_environment::ServerEnvironment;
use crate::configs::artcraft_2d::add_artcraft_2d;
use crate::configs::artcraft_3d::add_artcraft_3d;
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
use crate::configs::artcraft_desktop::add_artcraft_desktop;
use crate::configs::artcraft_webapp::add_artcraft_webapp;
use crate::configs::artcraft_website::add_artcraft_website;
use crate::configs::fakeyou::add_fakeyou;
use crate::configs::fakeyou::add_fakeyou_dev_proxy;
use crate::configs::legacy::add_legacy_storyteller_stream;
use crate::configs::legacy::add_legacy_trumped;
use crate::configs::legacy::add_legacy_vocodes;
use crate::configs::legacy::add_power_stream;
use crate::configs::realseedance::add_realseedance;
use crate::configs::storyteller::add_storyteller;
use crate::configs::storyteller::add_storyteller_dev_proxy;
use crate::configs::storyteller_board::add_storyteller_board;
use crate::configs::storyteller_render::add_storyteller_render;
use crate::configs::storyteller_studio::add_storyteller_studio;
use crate::configs::tauri::add_tauri;
use crate::cors::build_cors_config;
use crate::testing::{assert_exact_https_origin, is_origin_allowed, isolated_cors};
use Env::{Both, DevelopmentOnly, ProductionOnly};
type AddOrigins = fn(Cors, bool) -> Cors;
/// Which environments an origin is configured for.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Env {
Both,
ProductionOnly,
DevelopmentOnly,
}
const ALLOWLIST: &[(AddOrigins, Env, &str)] = &[
// add_artcraft_2d
(add_artcraft_2d, Both, "https://2d.storyteller.ai"),
(add_artcraft_2d, Both, "https://2d.getartcraft.com"),
(add_artcraft_2d, Both, "http://localhost:5741"),
(add_artcraft_2d, Both, "http://localhost:5742"),
(add_artcraft_2d, Both, "http://localhost:5743"),
// add_artcraft_3d
(add_artcraft_3d, Both, "https://3d.storyteller.ai"),
(add_artcraft_3d, Both, "https://3d.getartcraft.com"),
(add_artcraft_3d, Both, "http://localhost:5173"),
(add_artcraft_3d, Both, "http://localhost:5174"),
(add_artcraft_3d, Both, "http://localhost:5175"),
(add_artcraft_3d, Both, "https://macaroni-1.tailce84f.ts.net"),
(add_artcraft_3d, Both, "https://halide.tailce84f.ts.net"),
(add_artcraft_3d, Both, "https://brandons-macbook-pro.taild62114.ts.net"),
// add_artcraft_admin_dashboard
(add_artcraft_admin_dashboard, Both, "https://artcraft-dashboard.netlify.app"),
// add_artcraft_desktop
(add_artcraft_desktop, Both, "https://desktop.getartcraft.com"),
// add_artcraft_webapp
(add_artcraft_webapp, Both, "https://app.getartcraft.com"),
// add_artcraft_website
(add_artcraft_website, Both, "https://getartcraft.com"),
(add_artcraft_website, Both, "https://www.getartcraft.com"),
(add_artcraft_website, Both, "https://artcraft.ai"),
(add_artcraft_website, Both, "https://www.artcraft.ai"),
(add_artcraft_website, Both, "http://localhost:4200"),
(add_artcraft_website, Both, "http://localhost:4201"),
(add_artcraft_website, Both, "http://localhost:4202"),
// add_fakeyou
(add_fakeyou, Both, "https://engine.fakeyou.com"),
(add_fakeyou, ProductionOnly, "https://api.fakeyou.com"),
(add_fakeyou, ProductionOnly, "https://fakeyou.com"),
(add_fakeyou, ProductionOnly, "https://staging.fakeyou.com"),
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com"),
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com:7000"),
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com:7001"),
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com"),
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com:7000"),
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com:7001"),
// add_fakeyou_dev_proxy
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com"),
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:5173"),
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7000"),
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7001"),
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7002"),
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com"),
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:5173"),
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7000"),
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7001"),
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7002"),
// add_legacy_storyteller_stream
(add_legacy_storyteller_stream, ProductionOnly, "http://api.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "http://obs.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "http://storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "http://ws.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "https://api.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "https://obs.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "https://storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "https://ws.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "http://staging.obs.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "http://staging.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "https://staging.obs.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "https://staging.storyteller.stream"),
(add_legacy_storyteller_stream, ProductionOnly, "http://create.storyteller.ai"),
(add_legacy_storyteller_stream, ProductionOnly, "http://obs.storyteller.ai"),
(add_legacy_storyteller_stream, ProductionOnly, "http://ws.storyteller.ai"),
(add_legacy_storyteller_stream, ProductionOnly, "https://create.storyteller.ai"),
(add_legacy_storyteller_stream, ProductionOnly, "https://obs.storyteller.ai"),
(add_legacy_storyteller_stream, ProductionOnly, "https://ws.storyteller.ai"),
// add_legacy_trumped
(add_legacy_trumped, ProductionOnly, "https://trumped.com"),
(add_legacy_trumped, DevelopmentOnly, "http://dev.trumped.com"),
(add_legacy_trumped, DevelopmentOnly, "https://dev.trumped.com"),
// add_legacy_vocodes
(add_legacy_vocodes, ProductionOnly, "https://api.vo.codes"),
(add_legacy_vocodes, ProductionOnly, "https://vo.codes"),
(add_legacy_vocodes, ProductionOnly, "https://vocodes.com"),
(add_legacy_vocodes, DevelopmentOnly, "http://dev.api.vo.codes"),
(add_legacy_vocodes, DevelopmentOnly, "http://dev.vo.codes"),
(add_legacy_vocodes, DevelopmentOnly, "https://dev.api.vo.codes"),
(add_legacy_vocodes, DevelopmentOnly, "https://dev.vo.codes"),
// add_power_stream
(add_power_stream, ProductionOnly, "https://dash.power.stream"),
(add_power_stream, ProductionOnly, "https://power.stream"),
(add_power_stream, DevelopmentOnly, "http://dev.dash.power.stream"),
(add_power_stream, DevelopmentOnly, "http://dev.power.stream"),
(add_power_stream, DevelopmentOnly, "https://dev.dash.power.stream"),
(add_power_stream, DevelopmentOnly, "https://dev.power.stream"),
// add_realseedance
(add_realseedance, Both, "https://realseedance.com"),
(add_realseedance, Both, "https://www.realseedance.com"),
// add_storyteller
(add_storyteller, ProductionOnly, "https://engine.storyteller.ai"),
(add_storyteller, ProductionOnly, "https://studio.storyteller.ai"),
(add_storyteller, ProductionOnly, "https://api.storyteller.ai"),
(add_storyteller, ProductionOnly, "https://storyteller.ai"),
(add_storyteller, ProductionOnly, "https://staging.storyteller.ai"),
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai"),
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:5173"),
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7000"),
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7001"),
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7002"),
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai"),
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:5173"),
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7000"),
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7001"),
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7002"),
// add_storyteller_dev_proxy
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai"),
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:5173"),
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7000"),
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7001"),
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7002"),
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai"),
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:5173"),
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7000"),
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7001"),
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7002"),
// add_storyteller_board
(add_storyteller_board, Both, "https://memeboard.ai"),
(add_storyteller_board, Both, "https://dingboard.ai"),
(add_storyteller_board, Both, "https://board.storyteller.ai"),
(add_storyteller_board, Both, "http://localhost:5173/"),
// add_storyteller_render
(add_storyteller_render, Both, "https://render.storyteller.ai"),
// add_storyteller_studio
(add_storyteller_studio, Both, "https://studio.storyteller.ai"),
(add_storyteller_studio, Both, "https://studio-staging.studio.storyteller.ai"),
(add_storyteller_studio, Both, "https://studio-testing.studio.storyteller.ai"),
(add_storyteller_studio, Both, "http://localhost:5173"),
(add_storyteller_studio, Both, "https://animate.storyteller.ai"),
// add_tauri
(add_tauri, Both, "http://tauri.localhost"),
(add_tauri, Both, "tauri://localhost"),
];
/// The origins whose `allowed_origin(...)` literal became a `domains` constant.
const MIGRATED_ORIGINS: &[&str] = &[
"https://getartcraft.com",
"https://www.getartcraft.com",
"https://app.getartcraft.com",
"https://desktop.getartcraft.com",
"https://2d.getartcraft.com",
"https://3d.getartcraft.com",
"https://artcraft.ai",
"https://www.artcraft.ai",
"https://fakeyou.com",
"https://api.fakeyou.com",
"https://engine.fakeyou.com",
"https://staging.fakeyou.com",
"https://storyteller.ai",
"https://api.storyteller.ai",
"https://engine.storyteller.ai",
"https://staging.storyteller.ai",
"https://studio.storyteller.ai",
"https://studio-staging.studio.storyteller.ai",
"https://studio-testing.studio.storyteller.ai",
"https://animate.storyteller.ai",
"https://board.storyteller.ai",
"https://render.storyteller.ai",
"https://2d.storyteller.ai",
"https://3d.storyteller.ai",
];
mod isolated_module_tests {
use super::*;
#[actix_rt::test]
async fn each_module_allows_its_origins_in_production() {
let failures = allowed_failures(true, |env| env != DevelopmentOnly, false).await;
assert!(failures.is_empty(), "production origins not allowed by their module: {:#?}", failures);
}
#[actix_rt::test]
async fn each_module_allows_its_origins_in_development() {
let failures = allowed_failures(false, |env| env != ProductionOnly, false).await;
assert!(failures.is_empty(), "development origins not allowed by their module: {:#?}", failures);
}
#[actix_rt::test]
async fn development_only_origins_are_rejected_by_their_module_in_production() {
let failures = allowed_failures(true, |env| env == DevelopmentOnly, true).await;
assert!(failures.is_empty(), "development-only origins allowed in production: {:#?}", failures);
}
#[actix_rt::test]
async fn production_only_origins_are_rejected_by_their_module_in_development() {
let failures = allowed_failures(false, |env| env == ProductionOnly, true).await;
assert!(failures.is_empty(), "production-only origins allowed in development: {:#?}", failures);
}
/// Checks each matching row against its own module's isolated config and
/// returns the origins that were allowed (`expect_rejected`) or not.
async fn allowed_failures(
is_production: bool,
select: impl Fn(Env) -> bool,
expect_rejected: bool,
) -> Vec<&'static str> {
let mut failures = Vec::new();
for &(add_origins, env, origin) in ALLOWLIST {
if !select(env) {
continue;
}
let cors = isolated_cors(add_origins, is_production);
if is_origin_allowed(&cors, origin).await == expect_rejected {
failures.push(origin);
}
}
failures
}
}
mod full_config_tests {
use super::*;
#[actix_rt::test]
async fn production_config_allows_every_production_origin() {
let cors = build_cors_config(ServerEnvironment::Production);
let failures = full_config_failures(&cors, |env| env != DevelopmentOnly, false).await;
assert!(failures.is_empty(), "production origins not allowed: {:#?}", failures);
}
#[actix_rt::test]
async fn development_config_allows_every_development_origin() {
let cors = build_cors_config(ServerEnvironment::Development);
let failures = full_config_failures(&cors, |env| env != ProductionOnly, false).await;
assert!(failures.is_empty(), "development origins not allowed: {:#?}", failures);
}
#[actix_rt::test]
async fn production_config_rejects_development_only_origins() {
let cors = build_cors_config(ServerEnvironment::Production);
let failures = full_config_failures(&cors, |env| env == DevelopmentOnly, true).await;
assert!(failures.is_empty(), "development-only origins allowed in production: {:#?}", failures);
}
/// Every origin that moved to a `domains` constant, checked for lookalikes
/// against the full production config (not just the owning module).
#[actix_rt::test]
async fn production_config_rejects_lookalikes_of_migrated_origins() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in MIGRATED_ORIGINS {
assert_exact_https_origin(&cors, origin).await;
}
}
async fn full_config_failures(
cors: &Cors,
select: impl Fn(Env) -> bool,
expect_rejected: bool,
) -> Vec<&'static str> {
let mut failures = Vec::new();
for &(_, env, origin) in ALLOWLIST {
if select(env) && is_origin_allowed(cors, origin).await == expect_rejected {
failures.push(origin);
}
}
failures
}
}
@@ -1,11 +1,13 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::artcraft::ARTCRAFT_2D;
use domains::storyteller::STORYTELLER_2D;
pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
cors
// Hypothetical domains
.allowed_origin("https://2d.storyteller.ai")
.allowed_origin("https://2d.getartcraft.com")
.allowed_origin(STORYTELLER_2D.https_link_without_path())
.allowed_origin(ARTCRAFT_2D.https_link_without_path())
// Netlify project
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-2d.netlify.app")
@@ -15,3 +17,35 @@ pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
.allowed_origin("http://localhost:5742") // If already started
.allowed_origin("http://localhost:5743") // If already started
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
use super::add_artcraft_2d;
#[actix_rt::test]
async fn domains() {
for is_production in [true, false] {
let cors = isolated_cors(add_artcraft_2d, is_production);
assert_exact_https_origin(&cors, "https://2d.getartcraft.com").await;
assert_exact_https_origin(&cors, "https://2d.storyteller.ai").await;
}
}
#[actix_rt::test]
async fn tauri_localhost_ports() {
let cors = isolated_cors(add_artcraft_2d, true);
for origin in ["http://localhost:5741", "http://localhost:5742", "http://localhost:5743"] {
assert_origin_ok(&cors, origin).await;
}
assert_origin_invalid(&cors, "http://localhost:5744").await;
assert_origin_invalid(&cors, "http://localhost:5173").await; // The 3D engine's port
}
#[actix_rt::test]
async fn netlify_project() {
let cors = isolated_cors(add_artcraft_2d, true);
assert_netlify_project(&cors, "storyteller-2d.netlify.app").await;
}
}
@@ -1,11 +1,13 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::artcraft::ARTCRAFT_3D;
use domains::storyteller::STORYTELLER_3D;
pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
cors
// Hypothetical domains
.allowed_origin("https://3d.storyteller.ai")
.allowed_origin("https://3d.getartcraft.com")
.allowed_origin(STORYTELLER_3D.https_link_without_path())
.allowed_origin(ARTCRAFT_3D.https_link_without_path())
// Netlify project
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-3d.netlify.app")
@@ -18,3 +20,48 @@ pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
.allowed_origin("https://halide.tailce84f.ts.net") // TODO
.allowed_origin("https://brandons-macbook-pro.taild62114.ts.net") // TODO
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
use super::add_artcraft_3d;
#[actix_rt::test]
async fn domains() {
for is_production in [true, false] {
let cors = isolated_cors(add_artcraft_3d, is_production);
assert_exact_https_origin(&cors, "https://3d.getartcraft.com").await;
assert_exact_https_origin(&cors, "https://3d.storyteller.ai").await;
}
}
#[actix_rt::test]
async fn tauri_localhost_ports() {
let cors = isolated_cors(add_artcraft_3d, true);
for origin in ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"] {
assert_origin_ok(&cors, origin).await;
}
assert_origin_invalid(&cors, "http://localhost:5176").await;
assert_origin_invalid(&cors, "http://localhost:5741").await; // The 2D engine's port
}
#[actix_rt::test]
async fn tailscale_hosts() {
let cors = isolated_cors(add_artcraft_3d, true);
for origin in [
"https://macaroni-1.tailce84f.ts.net",
"https://halide.tailce84f.ts.net",
"https://brandons-macbook-pro.taild62114.ts.net",
] {
assert_exact_https_origin(&cors, origin).await;
}
assert_origin_invalid(&cors, "https://other-machine.tailce84f.ts.net").await;
}
#[actix_rt::test]
async fn netlify_project() {
let cors = isolated_cors(add_artcraft_3d, true);
assert_netlify_project(&cors, "storyteller-3d.netlify.app").await;
}
}
@@ -8,3 +8,18 @@ pub fn add_artcraft_admin_dashboard(cors: Cors, _is_production: bool) -> Cors {
netlify_branch_domain_matches(origin, "artcraft-dashboard.netlify.app")
})
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_netlify_project, isolated_cors};
use super::add_artcraft_admin_dashboard;
#[actix_rt::test]
async fn netlify_project() {
for is_production in [true, false] {
let cors = isolated_cors(add_artcraft_admin_dashboard, is_production);
assert_netlify_project(&cors, "artcraft-dashboard.netlify.app").await;
}
}
}
@@ -0,0 +1,67 @@
use actix_cors::Cors;
use domains::artcraft::ARTCRAFT_DESKTOP;
pub fn add_artcraft_desktop(cors: Cors, _is_production: bool) -> Cors {
cors
// Stable identity sent by the Artcraft native HTTP bridge (dev and packaged apps).
.allowed_origin(ARTCRAFT_DESKTOP.https_link_without_path())
}
#[cfg(test)]
mod tests {
use actix_web::dev::Transform;
use actix_web::http::{header, Method, StatusCode};
use actix_web::test::{self, TestRequest};
use server_environment::ServerEnvironment;
use crate::cors::build_cors_config;
use crate::testing::{assert_origin_invalid, assert_origin_ok};
const DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
#[actix_rt::test]
async fn desktop_requests_and_preflights_allow_credentials() {
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
let service = build_cors_config(environment)
.new_transform(test::ok_service())
.await
.unwrap();
for method in [Method::GET, Method::POST, Method::OPTIONS] {
let mut request = TestRequest::default()
.uri("/v1/session")
.insert_header((header::ORIGIN, DESKTOP_ORIGIN));
if method == Method::OPTIONS {
request = request
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
}
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), DESKTOP_ORIGIN);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
}
}
}
#[actix_rt::test]
async fn desktop_origin_is_an_exact_https_allowlist_entry() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in [
"http://desktop.getartcraft.com",
"https://desktop.getartcraft.com:8443",
"https://desktop.getartcraft.com.example.com",
"https://sub.desktop.getartcraft.com",
] {
assert_origin_invalid(&cors, origin).await;
}
}
#[actix_rt::test]
async fn existing_desktop_clients_remain_allowed() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in ["https://studio.storyteller.ai", "http://tauri.localhost", "tauri://localhost"] {
assert_origin_ok(&cors, origin).await;
}
}
}
@@ -1,12 +1,34 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::artcraft::ARTCRAFT_WEBAPP;
pub fn add_artcraft_webapp(cors: Cors, _is_production: bool) -> Cors {
cors
// Actual domains
.allowed_origin("https://app.getartcraft.com")
.allowed_origin(ARTCRAFT_WEBAPP.https_link_without_path())
// Netlify project
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "artcraft-webapp.netlify.app")
})
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_exact_https_origin, assert_netlify_project, isolated_cors};
use super::add_artcraft_webapp;
#[actix_rt::test]
async fn webapp_domain() {
for is_production in [true, false] {
let cors = isolated_cors(add_artcraft_webapp, is_production);
assert_exact_https_origin(&cors, "https://app.getartcraft.com").await;
}
}
#[actix_rt::test]
async fn netlify_project() {
let cors = isolated_cors(add_artcraft_webapp, true);
assert_netlify_project(&cors, "artcraft-webapp.netlify.app").await;
}
}
@@ -1,14 +1,18 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::artcraft::ARTCRAFT_AI;
use domains::artcraft::ARTCRAFT_AI_WWW;
use domains::artcraft::ARTCRAFT_WEBSITE;
use domains::artcraft::ARTCRAFT_WEBSITE_WWW;
pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
cors
// Actual domains
.allowed_origin("https://getartcraft.com")
.allowed_origin("https://www.getartcraft.com")
.allowed_origin(ARTCRAFT_WEBSITE.https_link_without_path())
.allowed_origin(ARTCRAFT_WEBSITE_WWW.https_link_without_path())
// Hypothetical domains
.allowed_origin("https://artcraft.ai")
.allowed_origin("https://www.artcraft.ai")
.allowed_origin(ARTCRAFT_AI.https_link_without_path())
.allowed_origin(ARTCRAFT_AI_WWW.https_link_without_path())
// Development against production
.allowed_origin("http://localhost:4200")
.allowed_origin("http://localhost:4201")
@@ -18,3 +22,42 @@ pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
netlify_branch_domain_matches(origin, "artcraft-website.netlify.app")
})
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
use super::add_artcraft_website;
#[actix_rt::test]
async fn website_domains() {
for is_production in [true, false] {
let cors = isolated_cors(add_artcraft_website, is_production);
for origin in [
"https://getartcraft.com",
"https://www.getartcraft.com",
"https://artcraft.ai",
"https://www.artcraft.ai",
] {
assert_exact_https_origin(&cors, origin).await;
}
}
}
/// Local frontend builds talk to the production API.
#[actix_rt::test]
async fn localhost_development_against_production() {
let cors = isolated_cors(add_artcraft_website, true);
for origin in ["http://localhost:4200", "http://localhost:4201", "http://localhost:4202"] {
assert_origin_ok(&cors, origin).await;
}
assert_origin_invalid(&cors, "http://localhost:4203").await;
assert_origin_invalid(&cors, "https://localhost:4200").await;
}
#[actix_rt::test]
async fn netlify_project() {
let cors = isolated_cors(add_artcraft_website, true);
assert_netlify_project(&cors, "artcraft-website.netlify.app").await;
}
}
@@ -25,3 +25,48 @@ pub fn add_development_only(cors: Cors) -> Cors {
}
})
}
#[cfg(test)]
mod tests {
use actix_cors::Cors;
use crate::testing::{assert_origin_invalid, assert_origin_ok};
use super::add_development_only;
#[actix_rt::test]
async fn any_localhost_is_allowed() {
let cors = development_only_cors();
for origin in [
"http://localhost",
"http://localhost:3000",
"https://localhost:8443",
"http://127.0.0.1",
"http://127.0.0.1:5173",
] {
assert_origin_ok(&cors, origin).await;
}
}
#[actix_rt::test]
async fn non_localhost_is_rejected() {
let cors = development_only_cors();
for origin in [
"https://getartcraft.com",
"http://localhost.evil.example",
"http://evil.localhost.example",
"http://127.0.0.2",
"http://192.168.1.10",
"http://[::1]", // NB: IPv6 loopback isn't allowed today
"not a url",
"null",
] {
assert_origin_invalid(&cors, origin).await;
}
}
fn development_only_cors() -> Cors {
add_development_only(Cors::default())
.block_on_origin_mismatch(true)
}
}
@@ -1,17 +1,21 @@
use actix_cors::Cors;
use log::warn;
use url::{Host, Url};
use domains::fakeyou::FAKEYOU;
use domains::fakeyou::FAKEYOU_API;
use domains::fakeyou::FAKEYOU_ENGINE;
use domains::fakeyou::FAKEYOU_STAGING;
pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
if is_production {
cors
// Storyteller Engine (Production)
.allowed_origin("https://engine.fakeyou.com")
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path())
// FakeYou (Production)
.allowed_origin("https://api.fakeyou.com")
.allowed_origin("https://fakeyou.com")
.allowed_origin(FAKEYOU_API.https_link_without_path())
.allowed_origin(FAKEYOU.https_link_without_path())
// FakeYou (Staging)
.allowed_origin("https://staging.fakeyou.com")
.allowed_origin(FAKEYOU_STAGING.https_link_without_path())
// Allow Netlify domains within "fakeyou" project.
.allowed_origin_fn(|origin, _req_head| {
let maybe_url = origin.to_str()
@@ -50,7 +54,7 @@ pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
.allowed_origin("https://dev.fakeyou.com:7000") // Yarn default port
.allowed_origin("https://dev.fakeyou.com:7001") // NB: Mac frontend
// Storyteller Engine (Development)
.allowed_origin("https://engine.fakeyou.com") // NB: We use prod for integration testing
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path()) // NB: We use prod for integration testing
}
}
@@ -75,3 +75,35 @@ pub fn add_legacy_trumped(cors: Cors, is_production: bool) -> Cors {
}
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_origin_invalid, assert_origin_ok, isolated_cors};
use super::{add_legacy_storyteller_stream, add_legacy_trumped, add_legacy_vocodes, add_power_stream};
// NB: The full per-environment origin lists are pinned in allowlist_regression_tests.
#[actix_rt::test]
async fn legacy_storyteller_stream_has_no_development_origins() {
let cors = isolated_cors(add_legacy_storyteller_stream, false);
assert_origin_invalid(&cors, "https://storyteller.stream").await;
assert_origin_invalid(&cors, "https://create.storyteller.ai").await;
}
#[actix_rt::test]
async fn production_hosts_are_exact() {
let power_stream = isolated_cors(add_power_stream, true);
assert_origin_ok(&power_stream, "https://power.stream").await;
assert_origin_invalid(&power_stream, "http://power.stream").await;
assert_origin_invalid(&power_stream, "https://power.stream.evil.example").await;
let vocodes = isolated_cors(add_legacy_vocodes, true);
assert_origin_ok(&vocodes, "https://vo.codes").await;
assert_origin_invalid(&vocodes, "https://evil.vo.codes").await;
let trumped = isolated_cors(add_legacy_trumped, true);
assert_origin_ok(&trumped, "https://trumped.com").await;
assert_origin_invalid(&trumped, "https://trumped.com:8443").await;
}
}
@@ -1,6 +1,7 @@
pub (crate) mod artcraft_admin_dashboard;
pub (crate) mod artcraft_2d;
pub (crate) mod artcraft_3d;
pub (crate) mod artcraft_desktop;
pub (crate) mod artcraft_webapp;
pub (crate) mod artcraft_website;
pub (crate) mod development_only;
@@ -11,4 +12,4 @@ pub (crate) mod storyteller;
pub (crate) mod storyteller_board;
pub (crate) mod storyteller_render;
pub (crate) mod storyteller_studio;
pub (crate) mod tauri;
pub (crate) mod tauri;
@@ -12,3 +12,25 @@ pub fn add_realseedance(cors: Cors, _is_production: bool) -> Cors {
netlify_branch_domain_matches(origin, "real-seedance.netlify.app")
})
}
#[cfg(test)]
mod tests {
use crate::testing::{assert_exact_https_origin, assert_netlify_project, isolated_cors};
use super::add_realseedance;
#[actix_rt::test]
async fn domains() {
for is_production in [true, false] {
let cors = isolated_cors(add_realseedance, is_production);
assert_exact_https_origin(&cors, "https://realseedance.com").await;
assert_exact_https_origin(&cors, "https://www.realseedance.com").await;
}
}
#[actix_rt::test]
async fn netlify_project() {
let cors = isolated_cors(add_realseedance, true);
assert_netlify_project(&cors, "real-seedance.netlify.app").await;
}
}
@@ -1,19 +1,26 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::storyteller::STORYTELLER;
use domains::storyteller::STORYTELLER_API;
use domains::storyteller::STORYTELLER_ENGINE;
use domains::storyteller::STORYTELLER_STAGING;
use domains::storyteller::STORYTELLER_STUDIO;
pub fn add_storyteller(cors: Cors, is_production: bool) -> Cors {
if is_production {
cors
// Storyteller Engine (Production)
.allowed_origin("https://engine.storyteller.ai")
.allowed_origin(STORYTELLER_ENGINE.https_link_without_path())
// Storyteller Studio (Production)
.allowed_origin("https://studio.storyteller.ai")
// DO NOT REMOVE: shipped ArtCraft desktop builds send this origin.
// See deployed_desktop_client_origin_tests.rs.
.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
// Storyteller.ai (Production)
.allowed_origin("https://api.storyteller.ai")
.allowed_origin("https://storyteller.ai")
.allowed_origin(STORYTELLER_API.https_link_without_path())
.allowed_origin(STORYTELLER.https_link_without_path())
// Storyteller.ai (Staging)
.allowed_origin("https://staging.storyteller.ai")
.allowed_origin(STORYTELLER_STAGING.https_link_without_path())
// Allow Netlify domains within "storyteller-ai" project.
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-ai.netlify.app")
@@ -1,13 +1,14 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::storyteller::STORYTELLER_BOARD;
pub fn add_storyteller_board(cors: Cors, _is_production: bool) -> Cors {
cors
// Hypothetical domains
.allowed_origin("https://memeboard.ai")
.allowed_origin("https://dingboard.ai")
.allowed_origin("https://board.storyteller.ai")
.allowed_origin(STORYTELLER_BOARD.https_link_without_path())
// Netlify project
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-board.netlify.app")
@@ -1,11 +1,12 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::storyteller::STORYTELLER_RENDER;
pub fn add_storyteller_render(cors: Cors, _is_production: bool) -> Cors {
cors
// Hypothetical domains
.allowed_origin("https://render.storyteller.ai")
.allowed_origin(STORYTELLER_RENDER.https_link_without_path())
// Netlify project
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-render.netlify.app")
@@ -1,11 +1,18 @@
use actix_cors::Cors;
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
use domains::storyteller::STORYTELLER_ANIMATE;
use domains::storyteller::STORYTELLER_STUDIO;
use domains::storyteller::STORYTELLER_STUDIO_STAGING;
use domains::storyteller::STORYTELLER_STUDIO_TESTING;
pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
cors.allowed_origin("https://studio.storyteller.ai")
.allowed_origin("https://studio-staging.studio.storyteller.ai")
.allowed_origin("https://studio-testing.studio.storyteller.ai")
cors
// DO NOT REMOVE: shipped ArtCraft desktop builds send this origin.
// See deployed_desktop_client_origin_tests.rs.
.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
.allowed_origin(STORYTELLER_STUDIO_STAGING.https_link_without_path())
.allowed_origin(STORYTELLER_STUDIO_TESTING.https_link_without_path())
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storytellerstudio.netlify.app")
})
@@ -13,7 +20,7 @@ pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
netlify_branch_domain_matches(origin, "pipeline-gottagofast.netlify.app")
})
.allowed_origin("http://localhost:5173")
.allowed_origin("https://animate.storyteller.ai") // NB: Gen2 Studio
.allowed_origin(STORYTELLER_ANIMATE.https_link_without_path()) // NB: Gen2 Studio
}
#[cfg(test)]
@@ -5,5 +5,90 @@ pub fn add_tauri(cors: Cors, _is_production: bool) -> Cors {
// Tauri Windows
.allowed_origin("http://tauri.localhost")
// Tauri Mac
// DO NOT REMOVE: shipped ArtCraft desktop builds send this origin.
// See deployed_desktop_client_origin_tests.rs.
.allowed_origin("tauri://localhost")
}
#[cfg(test)]
mod tests {
use actix_web::dev::Transform;
use actix_web::http::{header, Method, StatusCode};
use actix_web::test::{self, TestRequest};
use server_environment::ServerEnvironment;
use crate::cors::build_cors_config;
use crate::testing::assert_origin_invalid;
const WINDOWS_ORIGIN: &str = "http://tauri.localhost";
const MAC_ORIGIN: &str = "tauri://localhost";
mod windows {
use super::*;
#[actix_rt::test]
async fn requests_and_preflights_allow_credentials() {
assert_requests_and_preflights_allow_credentials(WINDOWS_ORIGIN).await;
}
#[actix_rt::test]
async fn production_rejects_origin_variants() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in [
"https://tauri.localhost",
"http://tauri.localhost:8080",
"http://tauri.localhost.example.com",
"http://sub.tauri.localhost",
] {
assert_origin_invalid(&cors, origin).await;
}
}
}
mod mac {
use super::*;
#[actix_rt::test]
async fn requests_and_preflights_allow_credentials() {
assert_requests_and_preflights_allow_credentials(MAC_ORIGIN).await;
}
#[actix_rt::test]
async fn production_rejects_origin_variants() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in [
"http://localhost",
"https://localhost",
"tauri://localhost:8080",
"tauri://localhost.example.com",
"tauri://sub.localhost",
] {
assert_origin_invalid(&cors, origin).await;
}
}
}
async fn assert_requests_and_preflights_allow_credentials(origin: &str) {
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
let service = build_cors_config(environment)
.new_transform(test::ok_service())
.await
.unwrap();
for method in [Method::GET, Method::POST, Method::OPTIONS] {
let mut request = TestRequest::default()
.uri("/v1/session")
.insert_header((header::ORIGIN, origin));
if method == Method::OPTIONS {
request = request
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
}
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
assert_eq!(response.status(), StatusCode::OK, "Origin: {}", origin);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), origin);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
}
}
}
}
@@ -4,6 +4,7 @@ use log::info;
use crate::configs::artcraft_2d::add_artcraft_2d;
use crate::configs::artcraft_3d::add_artcraft_3d;
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
use crate::configs::artcraft_desktop::add_artcraft_desktop;
use crate::configs::artcraft_webapp::add_artcraft_webapp;
use crate::configs::artcraft_website::add_artcraft_website;
use crate::configs::development_only::add_development_only;
@@ -48,6 +49,7 @@ fn do_build_cors_config(is_production: bool) -> Cors {
// Artcraft
cors = add_tauri(cors, is_production);
cors = add_artcraft_desktop(cors, is_production);
cors = add_artcraft_2d(cors, is_production);
cors = add_artcraft_3d(cors, is_production);
cors = add_artcraft_webapp(cors, is_production);
@@ -0,0 +1,110 @@
//! DO NOT REMOVE THESE ORIGINS: shipped ArtCraft desktop builds depend on them.
//!
//! Old ArtCraft desktop builds in the wild send one of these `Origin` headers:
//!
//! * `https://studio.storyteller.ai`
//! * `tauri://localhost` (macOS Tauri webview)
//!
//! Those builds can't be updated server-side. If the API stops allowing these
//! origins, those installs break: every credentialed request (login, session,
//! generation) fails CORS in the webview.
//!
//! These tests live in their own module on purpose, apart from the Storyteller
//! and Tauri configs, so reworking or deleting those configs (or their tests)
//! can't silently remove this protection. They use string literals, not
//! `domains` constants, so renaming a constant can't change what they check.
use actix_web::dev::Transform;
use actix_web::http::{header, Method, StatusCode};
use actix_web::test::{self, TestRequest};
use server_environment::ServerEnvironment;
use crate::cors::build_cors_config;
const STORYTELLER_STUDIO_ORIGIN: &str = "https://studio.storyteller.ai";
const TAURI_MAC_ORIGIN: &str = "tauri://localhost";
const ENVIRONMENTS: [ServerEnvironment; 2] = [ServerEnvironment::Production, ServerEnvironment::Development];
mod storyteller_studio_origin {
use super::*;
#[actix_rt::test]
async fn credentialed_requests_are_allowed() {
assert_credentialed_requests_allowed(STORYTELLER_STUDIO_ORIGIN).await;
}
#[actix_rt::test]
async fn preflights_are_allowed() {
assert_preflights_allowed(STORYTELLER_STUDIO_ORIGIN).await;
}
}
mod tauri_mac_origin {
use super::*;
#[actix_rt::test]
async fn credentialed_requests_are_allowed() {
assert_credentialed_requests_allowed(TAURI_MAC_ORIGIN).await;
}
#[actix_rt::test]
async fn preflights_are_allowed() {
assert_preflights_allowed(TAURI_MAC_ORIGIN).await;
}
}
/// Simple requests in every environment and method get the origin echoed
/// back with credentials allowed, which is what the webview requires.
async fn assert_credentialed_requests_allowed(origin: &str) {
for environment in ENVIRONMENTS {
let service = build_cors_config(environment)
.new_transform(test::ok_service())
.await
.unwrap();
for method in [Method::GET, Method::POST, Method::PUT, Method::DELETE] {
let request = TestRequest::default()
.method(method.clone())
.uri("/v1/session")
.insert_header((header::ORIGIN, origin))
.to_srv_request();
let response = test::call_service(&service, request).await;
let context = format!(
"Shipped ArtCraft desktop builds send Origin {} ({:?}, {}). DO NOT REMOVE this origin from the CORS config.",
origin, environment, method);
assert_eq!(response.status(), StatusCode::OK, "{}", context);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).map(|v| v.to_str().unwrap()), Some(origin), "{}", context);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).map(|v| v.to_str().unwrap()), Some("true"), "{}", context);
}
}
}
/// Preflights for the methods and headers the desktop app uses are accepted.
async fn assert_preflights_allowed(origin: &str) {
for environment in ENVIRONMENTS {
let service = build_cors_config(environment)
.new_transform(test::ok_service())
.await
.unwrap();
for requested_method in ["GET", "POST", "PUT", "DELETE"] {
let request = TestRequest::default()
.method(Method::OPTIONS)
.uri("/v1/session")
.insert_header((header::ORIGIN, origin))
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, requested_method))
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session"))
.to_srv_request();
let response = test::call_service(&service, request).await;
let context = format!(
"Shipped ArtCraft desktop builds send Origin {} ({:?}, preflight {}). DO NOT REMOVE this origin from the CORS config.",
origin, environment, requested_method);
assert_eq!(response.status(), StatusCode::OK, "{}", context);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).map(|v| v.to_str().unwrap()), Some(origin), "{}", context);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).map(|v| v.to_str().unwrap()), Some("true"), "{}", context);
}
}
}
+8
View File
@@ -27,3 +27,11 @@ pub (crate) mod util;
#[cfg(test)]
pub (crate) mod testing;
#[cfg(test)]
mod allowlist_regression_tests;
// Origins sent by shipped ArtCraft desktop builds. Kept apart from the configs
// they test so those can't be refactored away with them.
#[cfg(test)]
mod deployed_desktop_client_origin_tests;
@@ -7,6 +7,53 @@ use actix_web::test;
use actix_web::test::TestRequest;
use speculoos::asserting;
/// A config containing ONLY one module's origins, finished the same way as
/// the production config (mismatched origins get a 400). Testing modules in
/// isolation proves each module allows its own origins, rather than passing
/// because some other module happens to allow them too.
pub (crate) fn isolated_cors(add_origins: fn(Cors, bool) -> Cors, is_production: bool) -> Cors {
add_origins(Cors::default(), is_production)
.block_on_origin_mismatch(true)
}
/// Whether `cors` allows a simple request from `origin`, for tests that check
/// many origins and report every failure at once.
pub (crate) async fn is_origin_allowed(cors: &Cors, origin: &str) -> bool {
let status = make_test_request(cors, origin).await.status();
assert!(
status == StatusCode::OK || status == StatusCode::BAD_REQUEST,
"unexpected status {} for origin {}", status, origin);
status == StatusCode::OK
}
/// Assert that `cors` allows the exact `https://` origin but none of its
/// lookalikes: plain http, another port, a suffix attack, or a sub-subdomain.
/// Don't use this for Netlify hosts: their matcher checks the hostname only.
pub (crate) async fn assert_exact_https_origin(cors: &Cors, origin: &str) {
let host = origin.strip_prefix("https://")
.unwrap_or_else(|| panic!("expected an https:// origin, got {}", origin));
assert_origin_ok(cors, origin).await;
assert_origin_invalid(cors, &format!("http://{}", host)).await;
assert_origin_invalid(cors, &format!("https://{}:8443", host)).await;
assert_origin_invalid(cors, &format!("https://{}.evil.example", host)).await;
assert_origin_invalid(cors, &format!("https://evil.{}", host)).await;
}
/// Assert that `cors` allows a Netlify project's main, branch and
/// deploy-preview hosts, and rejects other projects' hosts.
pub (crate) async fn assert_netlify_project(cors: &Cors, netlify_hostname: &str) {
assert_origin_ok(cors, &format!("https://{}", netlify_hostname)).await;
assert_origin_ok(cors, &format!("https://my-branch--{}", netlify_hostname)).await;
assert_origin_ok(cors, &format!("https://deploy-preview-123--{}", netlify_hostname)).await;
assert_origin_invalid(cors, "https://unrelated-project.netlify.app").await;
assert_origin_invalid(cors, "https://deploy-preview-123--unrelated-project.netlify.app").await;
assert_origin_invalid(cors, &format!("https://{}.evil.example", netlify_hostname)).await;
// A hyphen alone isn't a branch separator; Netlify branch hosts use "--".
assert_origin_invalid(cors, &format!("https://evil-{}", netlify_hostname)).await;
}
pub (crate) async fn assert_origin_ok(cors: &Cors, hostname: &str) {
let response = make_test_request(cors, hostname).await;
asserting(&format!("Hostname {} is valid", hostname))
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "domains"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "domains"
path = "src/lib.rs"
[features]
default = []
# Adds `Domain::https_url()`, returning a cached `&'static url::Url`.
url = ["dep:url"]
# No dependencies by default: the domain constants and their string forms are
# built entirely at compile time.
[dependencies]
url = { workspace = true, optional = true }
+69
View File
@@ -0,0 +1,69 @@
//! ArtCraft domains.
use crate::{Domain, domain};
/// Public marketing website (Next.js).
pub const ARTCRAFT_WEBSITE: Domain = domain!("getartcraft.com");
pub const ARTCRAFT_WEBSITE_WWW: Domain = domain!("www.getartcraft.com");
/// Browser web application.
pub const ARTCRAFT_WEBAPP: Domain = domain!("app.getartcraft.com");
/// Desktop app landing page.
pub const ARTCRAFT_DESKTOP: Domain = domain!("desktop.getartcraft.com");
/// HTTP API.
pub const ARTCRAFT_API: Domain = domain!("api.getartcraft.com");
pub const ARTCRAFT_2D: Domain = domain!("2d.getartcraft.com");
pub const ARTCRAFT_3D: Domain = domain!("3d.getartcraft.com");
/// Alternate marketing domain.
pub const ARTCRAFT_AI: Domain = domain!("artcraft.ai");
pub const ARTCRAFT_AI_WWW: Domain = domain!("www.artcraft.ai");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn website() {
assert_eq!(ARTCRAFT_WEBSITE.https_link_without_path(), "https://getartcraft.com");
assert_eq!(ARTCRAFT_WEBSITE_WWW.https_link_without_path(), "https://www.getartcraft.com");
}
#[test]
fn webapp() {
assert_eq!(ARTCRAFT_WEBAPP.https_link_without_path(), "https://app.getartcraft.com");
}
#[test]
fn desktop() {
assert_eq!(ARTCRAFT_DESKTOP.bare_domain(), "desktop.getartcraft.com");
assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
assert_eq!(ARTCRAFT_DESKTOP.https_link_with_root_path(), "https://desktop.getartcraft.com/");
}
#[test]
fn api() {
assert_eq!(ARTCRAFT_API.https_link_without_path(), "https://api.getartcraft.com");
}
#[test]
fn two_d_and_three_d() {
assert_eq!(ARTCRAFT_2D.https_link_without_path(), "https://2d.getartcraft.com");
assert_eq!(ARTCRAFT_3D.https_link_without_path(), "https://3d.getartcraft.com");
}
#[test]
fn artcraft_ai() {
assert_eq!(ARTCRAFT_AI.https_link_without_path(), "https://artcraft.ai");
assert_eq!(ARTCRAFT_AI_WWW.https_link_without_path(), "https://www.artcraft.ai");
}
#[cfg(feature = "url")]
#[test]
fn desktop_url() {
assert_eq!(ARTCRAFT_DESKTOP.https_url().as_str(), "https://desktop.getartcraft.com/");
}
}
+163
View File
@@ -0,0 +1,163 @@
/// A validated domain name, with its `https://` link forms precomputed at
/// compile time. Build one with the [`domain!`](crate::domain) macro:
///
/// ```
/// use domains::{domain, Domain};
///
/// const EXAMPLE: Domain = domain!("desktop.example.com");
///
/// assert_eq!(EXAMPLE.bare_domain(), "desktop.example.com");
/// assert_eq!(EXAMPLE.https_link_without_path(), "https://desktop.example.com");
/// assert_eq!(EXAMPLE.https_link_with_root_path(), "https://desktop.example.com/");
/// ```
///
/// An invalid literal fails to compile:
///
/// ```compile_fail
/// const BAD: domains::Domain = domains::domain!("https://example.com");
/// ```
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct Domain {
bare: &'static str,
https_without_path: &'static str,
https_with_root_path: &'static str,
}
/// Declares a [`Domain`] from a string literal. Validation runs at compile
/// time: an invalid domain is a build error, never a runtime panic.
#[macro_export]
macro_rules! domain {
($domain:literal) => {
$crate::Domain::__from_macro(
$domain,
::core::concat!("https://", $domain),
::core::concat!("https://", $domain, "/"),
)
};
}
impl Domain {
/// Only for the `domain!` macro, which guarantees the three strings agree.
/// Panics (a compile error in const context) on an invalid domain.
#[doc(hidden)]
pub const fn __from_macro(
bare: &'static str,
https_without_path: &'static str,
https_with_root_path: &'static str,
) -> Self {
crate::validate::assert_valid_domain(bare);
Self { bare, https_without_path, https_with_root_path }
}
/// eg. `desktop.getartcraft.com`
pub const fn bare_domain(&self) -> &'static str {
self.bare
}
/// eg. `https://desktop.getartcraft.com`. This is also the browser `Origin`
/// header value, so use it for CORS and origin allowlists.
pub const fn https_link_without_path(&self) -> &'static str {
self.https_without_path
}
/// eg. `https://desktop.getartcraft.com/`
pub const fn https_link_with_root_path(&self) -> &'static str {
self.https_with_root_path
}
/// eg. `https://desktop.getartcraft.com/` as a `Url`.
///
/// Parsed on first use per domain, then served from a process-wide cache,
/// so every call returns the same `&'static Url`.
#[cfg(feature = "url")]
pub fn https_url(&self) -> &'static url::Url {
crate::url_cache::https_url(self.bare, self.https_with_root_path)
}
}
impl std::fmt::Debug for Domain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Domain({})", self.bare)
}
}
impl std::fmt::Display for Domain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.bare)
}
}
#[cfg(test)]
mod tests {
use crate::Domain;
const TEST_DOMAIN: Domain = domain!("desktop.example.com");
#[test]
fn bare_domain() {
assert_eq!(TEST_DOMAIN.bare_domain(), "desktop.example.com");
}
#[test]
fn https_links() {
assert_eq!(TEST_DOMAIN.https_link_without_path(), "https://desktop.example.com");
assert_eq!(TEST_DOMAIN.https_link_with_root_path(), "https://desktop.example.com/");
}
#[test]
fn usable_in_const_context() {
const ORIGINS: &[&str] = &[TEST_DOMAIN.https_link_without_path()];
assert_eq!(ORIGINS, &["https://desktop.example.com"]);
}
#[test]
fn display_and_debug() {
assert_eq!(TEST_DOMAIN.to_string(), "desktop.example.com");
assert_eq!(format!("{TEST_DOMAIN:?}"), "Domain(desktop.example.com)");
}
#[cfg(feature = "url")]
mod url_tests {
use super::*;
#[test]
fn https_url_has_root_path() {
let url = TEST_DOMAIN.https_url();
assert_eq!(url.as_str(), "https://desktop.example.com/");
assert_eq!(url.scheme(), "https");
assert_eq!(url.host_str(), Some("desktop.example.com"));
assert_eq!(url.path(), "/");
}
#[test]
fn https_url_is_parsed_once_and_shared() {
let first = TEST_DOMAIN.https_url();
let second = TEST_DOMAIN.https_url();
assert!(std::ptr::eq(first, second));
}
#[test]
fn https_url_is_shared_across_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Domain>();
assert_send_sync::<&'static url::Url>();
// Borrowed into 'static threads with no Arc or cloning; every thread
// sees the same parsed instance.
let main_thread_url = TEST_DOMAIN.https_url();
let handles: Vec<_> = (0..8)
.map(|_| std::thread::spawn(|| TEST_DOMAIN.https_url()))
.collect();
for handle in handles {
assert!(std::ptr::eq(handle.join().unwrap(), main_thread_url));
}
}
#[test]
fn https_url_matches_string_forms() {
let url = TEST_DOMAIN.https_url();
assert_eq!(url.as_str(), TEST_DOMAIN.https_link_with_root_path());
assert_eq!(url.origin().ascii_serialization(), TEST_DOMAIN.https_link_without_path());
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//! FakeYou domains.
use crate::{Domain, domain};
/// Main website.
pub const FAKEYOU: Domain = domain!("fakeyou.com");
/// HTTP API.
pub const FAKEYOU_API: Domain = domain!("api.fakeyou.com");
/// Engine frontend (also used against production for integration testing).
pub const FAKEYOU_ENGINE: Domain = domain!("engine.fakeyou.com");
pub const FAKEYOU_STAGING: Domain = domain!("staging.fakeyou.com");
/// Local development hosts (resolve to a developer machine).
pub const FAKEYOU_DEV: Domain = domain!("dev.fakeyou.com");
pub const FAKEYOU_DEV_PROXY: Domain = domain!("devproxy.fakeyou.com");
/// Production media CDN. Serves both FakeYou and Storyteller media.
pub const FAKEYOU_CDN: Domain = domain!("cdn-2.fakeyou.com");
/// Development media CDN (a Cloudflare R2 public bucket). Serves both FakeYou
/// and Storyteller development media, like `FAKEYOU_CDN` does in production.
pub const FAKEYOU_DEV_CDN: Domain = domain!("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn website() {
assert_eq!(FAKEYOU.bare_domain(), "fakeyou.com");
assert_eq!(FAKEYOU.https_link_without_path(), "https://fakeyou.com");
assert_eq!(FAKEYOU.https_link_with_root_path(), "https://fakeyou.com/");
}
#[test]
fn api() {
assert_eq!(FAKEYOU_API.https_link_without_path(), "https://api.fakeyou.com");
}
#[test]
fn engine_and_staging() {
assert_eq!(FAKEYOU_ENGINE.https_link_without_path(), "https://engine.fakeyou.com");
assert_eq!(FAKEYOU_STAGING.https_link_without_path(), "https://staging.fakeyou.com");
}
#[test]
fn development() {
assert_eq!(FAKEYOU_DEV.bare_domain(), "dev.fakeyou.com");
assert_eq!(FAKEYOU_DEV_PROXY.bare_domain(), "devproxy.fakeyou.com");
}
mod cdn {
use super::*;
#[test]
fn production() {
assert_eq!(FAKEYOU_CDN.bare_domain(), "cdn-2.fakeyou.com");
assert_eq!(FAKEYOU_CDN.https_link_without_path(), "https://cdn-2.fakeyou.com");
assert_eq!(FAKEYOU_CDN.https_link_with_root_path(), "https://cdn-2.fakeyou.com/");
}
#[test]
fn development() {
assert_eq!(FAKEYOU_DEV_CDN.https_link_without_path(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
}
#[cfg(feature = "url")]
#[test]
fn urls() {
assert_eq!(FAKEYOU_CDN.https_url().as_str(), "https://cdn-2.fakeyou.com/");
assert_eq!(FAKEYOU_DEV_CDN.https_url().as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
}
}
}
+21
View File
@@ -0,0 +1,21 @@
//! First-party domain names, validated at compile time.
//!
//! ```
//! use domains::artcraft::ARTCRAFT_DESKTOP;
//!
//! assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
//! ```
//!
//! Enable the `url` feature for `Domain::https_url()`, a cached `&'static url::Url`.
pub mod artcraft;
pub mod fakeyou;
pub mod storyteller;
mod domain;
mod validate;
#[cfg(feature = "url")]
mod url_cache;
pub use domain::Domain;
+80
View File
@@ -0,0 +1,80 @@
//! Storyteller domains. Storyteller media is served from `fakeyou::FAKEYOU_CDN`.
use crate::{Domain, domain};
/// Main website.
pub const STORYTELLER: Domain = domain!("storyteller.ai");
/// HTTP API.
pub const STORYTELLER_API: Domain = domain!("api.storyteller.ai");
pub const STORYTELLER_ENGINE: Domain = domain!("engine.storyteller.ai");
pub const STORYTELLER_STAGING: Domain = domain!("staging.storyteller.ai");
/// Storyteller Studio and its deploy environments.
///
/// NB: Shipped ArtCraft desktop builds send `https://studio.storyteller.ai` as
/// their CORS origin, so it must stay allowed (see `actix_cors_configs`).
pub const STORYTELLER_STUDIO: Domain = domain!("studio.storyteller.ai");
pub const STORYTELLER_STUDIO_STAGING: Domain = domain!("studio-staging.studio.storyteller.ai");
pub const STORYTELLER_STUDIO_TESTING: Domain = domain!("studio-testing.studio.storyteller.ai");
/// Gen2 Studio.
pub const STORYTELLER_ANIMATE: Domain = domain!("animate.storyteller.ai");
pub const STORYTELLER_BOARD: Domain = domain!("board.storyteller.ai");
pub const STORYTELLER_RENDER: Domain = domain!("render.storyteller.ai");
/// Legacy hosts for the ArtCraft 2D and 3D apps.
pub const STORYTELLER_2D: Domain = domain!("2d.storyteller.ai");
pub const STORYTELLER_3D: Domain = domain!("3d.storyteller.ai");
/// Local development hosts (resolve to a developer machine).
pub const STORYTELLER_DEV: Domain = domain!("dev.storyteller.ai");
pub const STORYTELLER_DEV_PROXY: Domain = domain!("devproxy.storyteller.ai");
/// Storyteller Studio static assets.
pub const STORYTELLER_CDN: Domain = domain!("cdn.storyteller.ai");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn website() {
assert_eq!(STORYTELLER.bare_domain(), "storyteller.ai");
assert_eq!(STORYTELLER.https_link_without_path(), "https://storyteller.ai");
assert_eq!(STORYTELLER.https_link_with_root_path(), "https://storyteller.ai/");
}
#[test]
fn api() {
assert_eq!(STORYTELLER_API.https_link_without_path(), "https://api.storyteller.ai");
}
#[test]
fn studio() {
assert_eq!(STORYTELLER_STUDIO.https_link_without_path(), "https://studio.storyteller.ai");
assert_eq!(STORYTELLER_STUDIO_STAGING.https_link_without_path(), "https://studio-staging.studio.storyteller.ai");
assert_eq!(STORYTELLER_STUDIO_TESTING.https_link_without_path(), "https://studio-testing.studio.storyteller.ai");
}
#[test]
fn apps() {
assert_eq!(STORYTELLER_BOARD.https_link_without_path(), "https://board.storyteller.ai");
assert_eq!(STORYTELLER_RENDER.https_link_without_path(), "https://render.storyteller.ai");
assert_eq!(STORYTELLER_2D.https_link_without_path(), "https://2d.storyteller.ai");
assert_eq!(STORYTELLER_3D.https_link_without_path(), "https://3d.storyteller.ai");
}
#[test]
fn development() {
assert_eq!(STORYTELLER_DEV.bare_domain(), "dev.storyteller.ai");
assert_eq!(STORYTELLER_DEV_PROXY.bare_domain(), "devproxy.storyteller.ai");
}
#[test]
fn cdn() {
assert_eq!(STORYTELLER_CDN.https_link_without_path(), "https://cdn.storyteller.ai");
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Process-wide cache backing `Domain::https_url()`.
//!
//! `Url::parse` can't run at compile time, and `Domain` stays free of
//! interior mutability so its constants remain usable in `const` contexts.
//! So each domain's `Url` is parsed on first use and leaked into this map.
//! Domains only come from `domain!` literals, so the set (and the leak) is
//! bounded by the constants in the source code.
use std::collections::BTreeMap;
use std::sync::Mutex;
use url::Url;
static URLS: Mutex<BTreeMap<&'static str, &'static Url>> = Mutex::new(BTreeMap::new());
pub(crate) fn https_url(bare: &'static str, https_with_root_path: &'static str) -> &'static Url {
// The map is only ever inserted into, so a poisoned lock still holds
// valid entries.
let mut urls = URLS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
urls.entry(bare).or_insert_with(|| {
// The domain was validated at compile time, so this parse can't fail.
let url = Url::parse(https_with_root_path).expect("compile-time validated domain should parse");
Box::leak(Box::new(url))
})
}
+217
View File
@@ -0,0 +1,217 @@
//! Compile-time domain name validation.
//!
//! `assert_valid_domain` is a `const fn` that panics on an invalid name. The
//! `domain!` macro calls it while evaluating a `const`/`static`, where a panic
//! is a compile error, so a bad domain literal never builds.
const MAX_DOMAIN_LEN: usize = 253;
const MAX_LABEL_LEN: usize = 63;
/// Panics (a compile error in const context) unless `domain` is a canonical,
/// fully-qualified DNS name: lowercase `a-z`, `0-9` and `-` labels separated
/// by single dots, at least two labels, no scheme, port, path or trailing dot,
/// and a non-numeric top-level label (so IP addresses are rejected).
pub const fn assert_valid_domain(domain: &str) {
match check_domain(domain) {
Ok(()) => {}
Err(DomainError::Empty) => panic!("invalid domain: empty"),
Err(DomainError::TooLong) => panic!("invalid domain: longer than 253 bytes"),
Err(DomainError::EmptyLabel) => panic!("invalid domain: empty label (leading, trailing or doubled dot)"),
Err(DomainError::LabelTooLong) => panic!("invalid domain: label longer than 63 bytes"),
Err(DomainError::InvalidCharacter) => panic!("invalid domain: only lowercase a-z, 0-9, '-' and '.' are allowed"),
Err(DomainError::HyphenAtLabelEdge) => panic!("invalid domain: label starts or ends with '-'"),
Err(DomainError::SingleLabel) => panic!("invalid domain: needs at least two labels (eg. example.com)"),
Err(DomainError::NumericTopLevelLabel) => panic!("invalid domain: top-level label is numeric (IP addresses are not domains)"),
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum DomainError {
Empty,
TooLong,
EmptyLabel,
LabelTooLong,
InvalidCharacter,
HyphenAtLabelEdge,
SingleLabel,
NumericTopLevelLabel,
}
pub(crate) const fn check_domain(domain: &str) -> Result<(), DomainError> {
let bytes = domain.as_bytes();
if bytes.is_empty() {
return Err(DomainError::Empty);
}
if bytes.len() > MAX_DOMAIN_LEN {
return Err(DomainError::TooLong);
}
let mut labels = 0;
let mut label_start = 0;
let mut last_label_start = 0;
let mut i = 0;
// Walk one past the end so the final label is checked like the others.
while i <= bytes.len() {
if i == bytes.len() || bytes[i] == b'.' {
match check_label(bytes, label_start, i) {
Ok(()) => {}
Err(err) => return Err(err),
}
labels += 1;
last_label_start = label_start;
label_start = i + 1;
}
i += 1;
}
if labels < 2 {
return Err(DomainError::SingleLabel);
}
if is_all_digits(bytes, last_label_start, bytes.len()) {
return Err(DomainError::NumericTopLevelLabel);
}
Ok(())
}
/// Checks `bytes[start..end]` as a single label.
const fn check_label(bytes: &[u8], start: usize, end: usize) -> Result<(), DomainError> {
let len = end - start;
if len == 0 {
return Err(DomainError::EmptyLabel);
}
if len > MAX_LABEL_LEN {
return Err(DomainError::LabelTooLong);
}
let mut i = start;
while i < end {
match bytes[i] {
b'a'..=b'z' | b'0'..=b'9' | b'-' => {}
_ => return Err(DomainError::InvalidCharacter),
}
i += 1;
}
if bytes[start] == b'-' || bytes[end - 1] == b'-' {
return Err(DomainError::HyphenAtLabelEdge);
}
Ok(())
}
const fn is_all_digits(bytes: &[u8], start: usize, end: usize) -> bool {
let mut i = start;
while i < end {
if !bytes[i].is_ascii_digit() {
return false;
}
i += 1;
}
true
}
#[cfg(test)]
mod tests {
use super::DomainError::*;
use super::check_domain;
mod valid {
use super::*;
#[test]
fn apex_domain() {
assert_eq!(check_domain("getartcraft.com"), Ok(()));
}
#[test]
fn subdomains() {
assert_eq!(check_domain("desktop.getartcraft.com"), Ok(()));
assert_eq!(check_domain("studio-staging.studio.storyteller.ai"), Ok(()));
}
#[test]
fn digits_and_hyphens_inside_labels() {
assert_eq!(check_domain("cdn-2.fakeyou.com"), Ok(()));
assert_eq!(check_domain("3d.getartcraft.com"), Ok(()));
assert_eq!(check_domain("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev"), Ok(()));
}
#[test]
fn max_lengths() {
let label_63 = "a".repeat(63);
assert_eq!(check_domain(&format!("{label_63}.com")), Ok(()));
// 63 + 1 + 63 + 1 + 63 + 1 + 61 = 253
let domain_253 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(61));
assert_eq!(domain_253.len(), 253);
assert_eq!(check_domain(&domain_253), Ok(()));
}
}
mod invalid {
use super::*;
#[test]
fn empty() {
assert_eq!(check_domain(""), Err(Empty));
}
#[test]
fn too_long() {
let label_63 = "a".repeat(63);
let domain_254 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(62));
assert_eq!(check_domain(&domain_254), Err(TooLong));
}
#[test]
fn empty_labels() {
assert_eq!(check_domain(".getartcraft.com"), Err(EmptyLabel));
assert_eq!(check_domain("getartcraft.com."), Err(EmptyLabel));
assert_eq!(check_domain("desktop..getartcraft.com"), Err(EmptyLabel));
assert_eq!(check_domain("."), Err(EmptyLabel));
}
#[test]
fn label_too_long() {
assert_eq!(check_domain(&format!("{}.com", "a".repeat(64))), Err(LabelTooLong));
}
#[test]
fn uppercase() {
assert_eq!(check_domain("GetArtCraft.com"), Err(InvalidCharacter));
}
#[test]
fn scheme_port_path_and_whitespace() {
assert_eq!(check_domain("https://getartcraft.com"), Err(InvalidCharacter));
assert_eq!(check_domain("getartcraft.com:443"), Err(InvalidCharacter));
assert_eq!(check_domain("getartcraft.com/"), Err(InvalidCharacter));
assert_eq!(check_domain(" getartcraft.com"), Err(InvalidCharacter));
}
#[test]
fn non_ascii() {
assert_eq!(check_domain("artcräft.com"), Err(InvalidCharacter));
}
#[test]
fn underscore() {
assert_eq!(check_domain("dev_proxy.fakeyou.com"), Err(InvalidCharacter));
}
#[test]
fn hyphen_at_label_edge() {
assert_eq!(check_domain("-desktop.getartcraft.com"), Err(HyphenAtLabelEdge));
assert_eq!(check_domain("desktop-.getartcraft.com"), Err(HyphenAtLabelEdge));
assert_eq!(check_domain("getartcraft.com-"), Err(HyphenAtLabelEdge));
}
#[test]
fn single_label() {
assert_eq!(check_domain("localhost"), Err(SingleLabel));
}
#[test]
fn ip_addresses() {
assert_eq!(check_domain("127.0.0.1"), Err(NumericTopLevelLabel));
assert_eq!(check_domain("example.123"), Err(NumericTopLevelLabel));
}
}
}
@@ -0,0 +1,145 @@
use log::info;
use std::path::Path;
use std::process::Command;
use errors::AnyhowResult;
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
use crate::ffmpeg::webp_ffmpeg_binary::webp_ffmpeg;
/// Lossy WebP quality (0-100). Started at 60 on 2026-09-28; on the test video
/// this was ~50x smaller than the GIF preview (23 KB vs 1.1 MB). Safe to tune:
/// it only affects newly generated previews.
const WEBP_QUALITY: &str = "60";
pub struct FfmpegVideoWebpPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
pub input_video_path: I,
pub output_webp_path: O,
}
/// Extract a short, endlessly looping animated WebP preview from a video file.
///
/// Uses the same frames as the GIF preview (`ffmpeg_video_gif_preview`): the
/// first 5 seconds, resampled to 10 fps, scaled to fit within 360x360
/// (preserving aspect ratio). Runs the ffmpeg chosen by
/// `webp_ffmpeg_binary::resolve_webp_ffmpeg` (call it at startup).
pub fn ffmpeg_video_webp_preview<I: AsRef<Path>, O: AsRef<Path>>(
args: FfmpegVideoWebpPreviewArgs<I, O>,
) -> AnyhowResult<()> {
let mut command = Command::new(webp_ffmpeg()?);
command
.arg("-nostdin")
.arg("-y")
.arg("-loglevel").arg("error")
.arg("-nostats")
.arg("-threads").arg("1")
.arg("-filter_complex_threads").arg("1")
.arg("-ss").arg("0")
.arg("-to").arg("5")
.arg("-i").arg(args.input_video_path.as_ref())
.arg("-threads").arg("1")
.arg("-filter_complex")
.arg("fps=10,scale=360:360:force_original_aspect_ratio=decrease")
.arg("-frames:v").arg("50")
.arg("-an")
.arg("-c:v").arg("libwebp_anim")
.arg("-lossless").arg("0")
.arg("-quality").arg(WEBP_QUALITY)
// NB: The webp muxer plays animations once by default; 0 loops forever like the GIF.
.arg("-loop").arg("0")
.arg(args.output_webp_path.as_ref());
info!("Calling ffmpeg (webp preview)...");
run_thumbnail_ffmpeg(command)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ffmpeg::webp_ffmpeg_binary::resolve_webp_ffmpeg;
use tempdir::TempDir;
use test_utils::test_file_path::test_file_path;
#[test]
fn test_extract_webp_preview_from_mp4() {
if !libwebp_anim_available() {
return;
}
let input_path = test_file_path("test_data/video/mp4/golden_sun_garoh.mp4")
.expect("test video should exist");
let temp_dir = TempDir::new_in("/tmp", "ffmpeg_webp_preview_test")
.expect("should create temp dir");
let output_path = temp_dir.path().join("preview.webp");
ffmpeg_video_webp_preview(FfmpegVideoWebpPreviewArgs {
input_video_path: &input_path,
output_webp_path: &output_path,
}).expect("ffmpeg should succeed");
let bytes = std::fs::read(&output_path).expect("output webp should exist");
// RIFF container holding a WebP file.
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WEBP");
// Animated WebP: an ANIM chunk with a loop count of 0 (loop forever).
let anim = find_chunk(&bytes, b"ANIM").expect("webp should be animated");
let loop_count = u16::from_le_bytes([bytes[anim + 12], bytes[anim + 13]]);
assert_eq!(loop_count, 0, "preview should loop forever");
// More than one frame, and no more than the 50 we asked for.
let frames = count_chunks(&bytes, b"ANMF");
assert!(frames > 1 && frames <= 50, "unexpected frame count: {}", frames);
// 640x480 input fits within 360x360 as 360x270. The VP8X chunk stores the
// canvas width-1 and height-1 as 24-bit little-endian values. (Frames
// after the first may be smaller sub-rectangles, so check the canvas.)
let vp8x = find_chunk(&bytes, b"VP8X").expect("animated webp should have a VP8X header");
let width = u24_le(&bytes[vp8x + 12..vp8x + 15]) + 1;
let height = u24_le(&bytes[vp8x + 15..vp8x + 18]) + 1;
assert_eq!((width, height), (360, 270));
}
/// Resolves like a development job would (including the macOS `ffmpeg-full`
/// fallback). Skips if this machine has no webp-capable ffmpeg at all.
fn libwebp_anim_available() -> bool {
match resolve_webp_ffmpeg(true) {
Ok(_) => true,
Err(err) => {
eprintln!("SKIPPED: {}", err);
false
}
}
}
/// Offset of the first RIFF chunk with this FourCC, walking top-level chunks
/// after the 12-byte RIFF/WEBP header.
fn find_chunk(bytes: &[u8], fourcc: &[u8; 4]) -> Option<usize> {
chunk_offsets(bytes).into_iter().find(|&offset| &bytes[offset..offset + 4] == fourcc)
}
fn count_chunks(bytes: &[u8], fourcc: &[u8; 4]) -> usize {
chunk_offsets(bytes).into_iter().filter(|&offset| &bytes[offset..offset + 4] == fourcc).count()
}
fn chunk_offsets(bytes: &[u8]) -> Vec<usize> {
let mut offsets = Vec::new();
let mut offset = 12;
while offset + 8 <= bytes.len() {
offsets.push(offset);
let size = u32::from_le_bytes([bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]]) as usize;
// Chunk payloads are padded to an even length.
offset += 8 + size + (size & 1);
}
offsets
}
fn u24_le(bytes: &[u8]) -> u32 {
u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16)
}
}
@@ -3,4 +3,6 @@ pub mod ffmpeg_transcode_to_mp4;
pub mod ffmpeg_trim_and_resample;
pub mod ffmpeg_video_first_frame_to_jpg_thumbnail;
pub mod ffmpeg_video_gif_preview;
pub mod ffmpeg_video_webp_preview;
mod run_thumbnail_ffmpeg;
pub mod webp_ffmpeg_binary;
@@ -0,0 +1,156 @@
//! Which `ffmpeg` binary to use for animated webp previews.
//!
//! Animated webp needs ffmpeg's `libwebp_anim` encoder. Ubuntu's `ffmpeg`
//! package (production) has it. Homebrew's slimmed `ffmpeg` formula doesn't
//! (as of 2026-09); its full build is the keg-only `ffmpeg-full` formula,
//! which isn't on the PATH. So on macOS development machines we fall back to
//! `ffmpeg-full` when the `ffmpeg` on the PATH can't encode webp.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use anyhow::anyhow;
use log::{info, warn};
use errors::AnyhowResult;
/// The `ffmpeg` on the PATH.
const PATH_FFMPEG: &str = "ffmpeg";
/// Homebrew's keg-only `ffmpeg-full` (Apple Silicon, then Intel prefixes).
const HOMEBREW_FFMPEG_FULL_CANDIDATES: [&str; 2] = [
"/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg",
"/usr/local/opt/ffmpeg-full/bin/ffmpeg",
];
/// The process-wide decision. Resolved once (the first caller probes the
/// binaries while concurrent callers wait), then shared by every thread.
/// Failures are cached too: a missing encoder won't appear mid-process.
static WEBP_FFMPEG: OnceLock<Result<PathBuf, String>> = OnceLock::new();
/// Resolve (once per process, then cached) the `ffmpeg` binary that can
/// encode animated webp.
///
/// Uses the `ffmpeg` on the PATH if it has `libwebp_anim`. Otherwise, only when
/// `is_development` and on macOS, falls back to Homebrew's `ffmpeg-full` and
/// logs a warning. Call this at startup with the server environment; the first
/// call decides for the whole process, and later calls return the cached
/// decision (whatever `is_development` they pass).
pub fn resolve_webp_ffmpeg(is_development: bool) -> AnyhowResult<&'static Path> {
let decision = WEBP_FFMPEG.get_or_init(|| {
let allow_macos_fallback = is_development && cfg!(target_os = "macos");
let decision = choose_webp_ffmpeg(
has_webp_encoder(Path::new(PATH_FFMPEG)),
allow_macos_fallback,
find_homebrew_ffmpeg_full,
);
match &decision {
Ok(WebpFfmpegChoice::Path) =>
info!("Using the ffmpeg on the PATH for webp previews."),
Ok(WebpFfmpegChoice::HomebrewFallback(binary)) =>
warn!("The ffmpeg on the PATH has no libwebp_anim encoder; falling back to {} for webp previews \
(macOS development only).", binary.display()),
Err(err) =>
warn!("No ffmpeg can encode webp previews: {}", err),
}
decision.map(WebpFfmpegChoice::into_binary)
});
decision.as_deref().map_err(|err| anyhow!("{}", err))
}
/// The binary resolved by [`resolve_webp_ffmpeg`], or production behavior
/// (the PATH only) if nothing resolved it yet.
pub(crate) fn webp_ffmpeg() -> AnyhowResult<&'static Path> {
resolve_webp_ffmpeg(false)
}
#[derive(Debug, PartialEq, Eq)]
enum WebpFfmpegChoice {
Path,
HomebrewFallback(PathBuf),
}
impl WebpFfmpegChoice {
fn into_binary(self) -> PathBuf {
match self {
WebpFfmpegChoice::Path => PathBuf::from(PATH_FFMPEG),
WebpFfmpegChoice::HomebrewFallback(binary) => binary,
}
}
}
/// The decision, separated from probing binaries so it can be tested.
fn choose_webp_ffmpeg(
path_ffmpeg_has_webp: bool,
allow_macos_fallback: bool,
find_fallback: impl FnOnce() -> Option<PathBuf>,
) -> Result<WebpFfmpegChoice, String> {
if path_ffmpeg_has_webp {
return Ok(WebpFfmpegChoice::Path);
}
if !allow_macos_fallback {
return Err("the ffmpeg on the PATH has no libwebp_anim encoder, which animated webp thumbnails need".to_string());
}
find_fallback()
.map(WebpFfmpegChoice::HomebrewFallback)
.ok_or_else(|| "neither the ffmpeg on the PATH nor Homebrew's ffmpeg-full has a libwebp_anim encoder. \
Run `brew install ffmpeg-full`.".to_string())
}
/// The first installed Homebrew `ffmpeg-full` that has the webp encoder.
fn find_homebrew_ffmpeg_full() -> Option<PathBuf> {
HOMEBREW_FFMPEG_FULL_CANDIDATES.iter()
.map(PathBuf::from)
.find(|binary| binary.exists() && has_webp_encoder(binary))
}
/// Whether `binary -encoders` lists `libwebp_anim`. False if it can't run.
fn has_webp_encoder(binary: &Path) -> bool {
Command::new(binary)
.args(["-hide_banner", "-encoders"])
.output()
.map(|output| String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.any(|word| word == "libwebp_anim"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
const FALLBACK: &str = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg";
#[test]
fn uses_path_ffmpeg_when_it_has_webp() {
let choice = choose_webp_ffmpeg(true, true, || panic!("shouldn't look for a fallback"));
assert_eq!(choice, Ok(WebpFfmpegChoice::Path));
}
#[test]
fn falls_back_to_ffmpeg_full_when_allowed() {
let choice = choose_webp_ffmpeg(false, true, || Some(PathBuf::from(FALLBACK)));
assert_eq!(choice, Ok(WebpFfmpegChoice::HomebrewFallback(PathBuf::from(FALLBACK))));
}
#[test]
fn never_falls_back_outside_macos_development() {
let choice = choose_webp_ffmpeg(false, false, || panic!("shouldn't look for a fallback"));
assert!(choice.is_err());
}
#[test]
fn errors_when_the_fallback_is_missing() {
let choice = choose_webp_ffmpeg(false, true, || None);
let err = choice.unwrap_err();
assert!(err.contains("brew install ffmpeg-full"), "{}", err);
}
#[test]
fn choice_binaries() {
assert_eq!(WebpFfmpegChoice::Path.into_binary(), PathBuf::from("ffmpeg"));
assert_eq!(WebpFfmpegChoice::HomebrewFallback(PathBuf::from(FALLBACK)).into_binary(), PathBuf::from(FALLBACK));
}
}
@@ -1,16 +1,176 @@
//! Shared config for thumbnails.
//! Shared config for video thumbnails (the still and animated previews stored
//! beside each video file in the public bucket).
//!
//! # Thumbnail versions (`media_files.maybe_thumbnail_version`)
//!
//! | Version | Since | Files beside `<video>.mp4` |
//! |---------|--------------------------|------------------------------------------------------------|
//! | `NULL` | 2024-02-28 | `-thumb.jpg`, `-thumb.gif` (legacy, or not yet generated) |
//! | `1` | 2026-03-18 | `-thumb.jpg`, `-thumb.gif` |
//! | `2` | 2026-09 (see below) | `-thumb.jpg`, `-thumb.webp`, `-thumb.gif` |
//!
//! * **`NULL`**: No version recorded. Clients have linked `-thumb.gif` and
//! `-thumb.jpg` since 2024-02-28 ("video card thumbnails", e7a427289f), so
//! older rows may have GIF/JPG files from earlier pipelines, or none at all.
//! A `NULL` row may also just be waiting for the thumbnail job. Serving uses
//! the `created_at` cutover in [`animated_preview_format`] to pick GIF or WebP.
//! * **`1`**: Written by `video-thumbnail-job` since it launched on 2026-03-18
//! (#1157). A first-frame JPG and an animated GIF: the first 5 seconds at
//! 10 fps, fit within 360x360.
//! * **`2`**: Same JPG, plus an animated WebP (quality 60) of the same frames,
//! which is typically 6-50x smaller than the GIF. The job still writes the
//! GIF too ("dual writes"); see "macOS Catalina" below. Deployed in late
//! September 2026. TODO(bt): Replace "2026-09" above with the exact deploy date.
//!
//! # macOS Catalina and GIF dual writes
//!
//! Animated WebP needs WebKit on macOS 11 (Big Sur) or newer. ArtCraft desktop
//! (Tauri) uses the system WKWebView, so on macOS 10.15 (Catalina) a WebP in
//! an `<img>` doesn't render. Version 2 therefore keeps writing `-thumb.gif`
//! beside the WebP, and the API exposes it as the Catalina fallback so the
//! desktop app can fall back to it. We may keep the GIF dual writes forever;
//! drop them only once no supported client needs them.
//!
//! # Changing this scheme
//!
//! These suffixes are how files are physically named in the public bucket and
//! are shared between services. Never change or reuse a suffix or a version
//! number. Add a new version instead, and update [`animated_preview_format`].
/// NB: DO NOT CHANGE WITHOUT MIGRATION - THIS IS SHARED BETWEEN SERVICES
/// Video media files have conventional gif thumbnails with this suffix.
/// This is how files are physically named in the public bucket.
pub const VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX: &str = "-thumb.gif";
/// NB: DO NOT CHANGE WITHOUT MIGRATION - THIS IS SHARED BETWEEN SERVICES
/// Video media files with thumbnail version 2+ have animated webp thumbnails
/// with this suffix. This is how files are physically named in the public bucket.
pub const VIDEO_ANIMATED_WEBP_THUMBNAIL_SUFFIX: &str = "-thumb.webp";
/// NB: DO NOT CHANGE WITHOUT MIGRATION - THIS IS SHARED BETWEEN SERVICES
/// Video media files have conventional static jpg thumbnails with this suffix.
/// This is how files are physically named in the public bucket.
pub const VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX: &str = "-thumb.jpg";
/// The current thumbnail format is a dual jpg + gif of a set size.
/// We write this as version "1". If we change the thumbnails in the behavior,
/// we may increment this number so that we can backfill.
pub const CURRENT_VIDEO_THUMBNAIL_VERSION : u8 = 1;
/// Version 1 (2026-03-18): first-frame jpg + animated gif.
pub const VIDEO_THUMBNAIL_VERSION_GIF: u8 = 1;
/// Version 2 (2026-09): first-frame jpg + animated webp, plus the animated gif
/// as a dual write for macOS Catalina.
pub const VIDEO_THUMBNAIL_VERSION_WEBP: u8 = 2;
/// The version `video-thumbnail-job` writes for newly processed videos.
pub const CURRENT_VIDEO_THUMBNAIL_VERSION: u8 = VIDEO_THUMBNAIL_VERSION_WEBP;
/// Videos with a `NULL` thumbnail version created at or after this instant
/// (2026-10-10T00:00:00Z) are served WebP previews; older `NULL` rows get GIF.
///
/// TODO(bt): Remove this cutover; it shouldn't live forever. It exists
/// because `NULL` means two things today: "legacy row that only ever had a
/// GIF" and "new row the thumbnail job hasn't reached yet (it will write a
/// WebP)". To remove it:
/// 1. Wait until the thumbnail job's lookback window has passed since
/// 2026-10-10, so every row created after the cutover has been processed.
/// 2. Backfill legacy rows older than the lookback window, eg.
/// `UPDATE media_files SET maybe_thumbnail_version = 1
/// WHERE maybe_thumbnail_version IS NULL AND media_class = 'video'
/// AND created_at < '2026-10-10'`.
/// (Rows inside the lookback window must be left `NULL`, or the job
/// will skip them.)
/// 3. `NULL` then only means "not generated yet", so serve the current
/// format for it: delete this constant and the `created_at` argument to
/// [`animated_preview_format`], and stop selecting `created_at` for it.
pub const NULL_THUMBNAIL_VERSION_WEBP_CUTOVER_UNIX_SECONDS: i64 = 1_791_590_400;
/// The format of a video's animated preview file.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AnimatedPreviewFormat {
Gif,
Webp,
}
impl AnimatedPreviewFormat {
/// The bucket file suffix, eg. `-thumb.webp`.
pub const fn suffix(self) -> &'static str {
match self {
Self::Gif => VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX,
Self::Webp => VIDEO_ANIMATED_WEBP_THUMBNAIL_SUFFIX,
}
}
}
/// Which animated preview to serve for a video, from its
/// `media_files.maybe_thumbnail_version` and `created_at` (as Unix seconds).
/// See the module docs for the version history.
pub fn animated_preview_format(
maybe_thumbnail_version: Option<u8>,
created_at_unix_seconds: i64,
) -> AnimatedPreviewFormat {
match maybe_thumbnail_version {
Some(version) if version >= VIDEO_THUMBNAIL_VERSION_WEBP => AnimatedPreviewFormat::Webp,
Some(_) => AnimatedPreviewFormat::Gif,
None if created_at_unix_seconds >= NULL_THUMBNAIL_VERSION_WEBP_CUTOVER_UNIX_SECONDS => AnimatedPreviewFormat::Webp,
None => AnimatedPreviewFormat::Gif,
}
}
#[cfg(test)]
mod tests {
use super::*;
// 2026-10-09T23:59:59Z and 2026-10-10T00:00:00Z
const JUST_BEFORE_CUTOVER: i64 = 1_791_590_399;
const AT_CUTOVER: i64 = 1_791_590_400;
// 2024-05-01T00:00:00Z
const LEGACY_ERA: i64 = 1_714_521_600;
#[test]
fn suffixes_are_stable() {
assert_eq!(VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX, "-thumb.gif");
assert_eq!(VIDEO_ANIMATED_WEBP_THUMBNAIL_SUFFIX, "-thumb.webp");
assert_eq!(VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX, "-thumb.jpg");
assert_eq!(AnimatedPreviewFormat::Gif.suffix(), "-thumb.gif");
assert_eq!(AnimatedPreviewFormat::Webp.suffix(), "-thumb.webp");
}
#[test]
fn versions_are_stable() {
assert_eq!(VIDEO_THUMBNAIL_VERSION_GIF, 1);
assert_eq!(VIDEO_THUMBNAIL_VERSION_WEBP, 2);
assert_eq!(CURRENT_VIDEO_THUMBNAIL_VERSION, 2);
}
mod animated_preview_format_tests {
use super::*;
#[test]
fn version_1_is_gif_regardless_of_date() {
assert_eq!(animated_preview_format(Some(1), LEGACY_ERA), AnimatedPreviewFormat::Gif);
assert_eq!(animated_preview_format(Some(1), AT_CUTOVER), AnimatedPreviewFormat::Gif);
}
#[test]
fn version_2_is_webp_regardless_of_date() {
assert_eq!(animated_preview_format(Some(2), LEGACY_ERA), AnimatedPreviewFormat::Webp);
assert_eq!(animated_preview_format(Some(2), AT_CUTOVER), AnimatedPreviewFormat::Webp);
}
#[test]
fn null_before_cutover_is_gif() {
assert_eq!(animated_preview_format(None, LEGACY_ERA), AnimatedPreviewFormat::Gif);
assert_eq!(animated_preview_format(None, JUST_BEFORE_CUTOVER), AnimatedPreviewFormat::Gif);
}
#[test]
fn null_at_or_after_cutover_is_webp() {
assert_eq!(animated_preview_format(None, AT_CUTOVER), AnimatedPreviewFormat::Webp);
assert_eq!(animated_preview_format(None, AT_CUTOVER + 86_400), AnimatedPreviewFormat::Webp);
}
#[test]
fn cutover_is_2026_10_10_utc() {
// 20_736 days from 1970-01-01 to 2026-10-10.
assert_eq!(NULL_THUMBNAIL_VERSION_WEBP_CUTOVER_UNIX_SECONDS, 20_736 * 86_400);
}
}
}
@@ -34,6 +34,8 @@ pub struct FolderMediaFileRow {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
/// Which video preview files exist. See `video_thumbnail_suffixes` in `bucket_paths`.
pub maybe_thumbnail_version: Option<u8>,
// Cover image's bucket fields, from a LEFT JOIN on
// mf.maybe_cover_image_media_file_token. All three are Some-or-None
@@ -102,6 +104,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -150,6 +153,7 @@ LIMIT ?
public_bucket_directory_hash: r.public_bucket_directory_hash,
maybe_public_bucket_prefix: r.maybe_public_bucket_prefix,
maybe_public_bucket_extension: r.maybe_public_bucket_extension,
maybe_thumbnail_version: r.maybe_thumbnail_version,
maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash,
maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix,
maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension,
@@ -183,6 +187,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -229,6 +234,7 @@ LIMIT ?
public_bucket_directory_hash: r.public_bucket_directory_hash,
maybe_public_bucket_prefix: r.maybe_public_bucket_prefix,
maybe_public_bucket_extension: r.maybe_public_bucket_extension,
maybe_thumbnail_version: r.maybe_thumbnail_version,
maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash,
maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix,
maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension,
@@ -98,6 +98,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -168,6 +169,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -27,6 +27,7 @@ pub struct JobMediaFileRecord {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
@@ -78,6 +79,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,
media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,
@@ -117,6 +117,8 @@ SELECT
media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,
media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,
media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,
media_files.maybe_thumbnail_version as maybe_media_file_thumbnail_version,
media_files.created_at as maybe_media_file_created_at,
jobs.assigned_worker as maybe_assigned_worker,
jobs.assigned_cluster as maybe_assigned_cluster,
@@ -246,6 +248,8 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
public_bucket_location_or_hash: public_bucket_hash.to_string(),
maybe_media_file_public_bucket_prefix: record.maybe_media_file_public_bucket_prefix.clone(),
maybe_media_file_public_bucket_extension: record.maybe_media_file_public_bucket_extension.clone(),
maybe_media_file_thumbnail_version: record.maybe_media_file_thumbnail_version,
maybe_media_file_created_at: record.maybe_media_file_created_at,
public_bucket_location_is_hash: bucket_path_is_hash,
maybe_successfully_completed_at: record.maybe_successfully_completed_at,
}
@@ -320,6 +324,8 @@ struct RawGenericInferenceJobStatus {
pub maybe_media_file_public_bucket_directory_hash: Option<String>, // NB: This is the bucket directory hash
pub maybe_media_file_public_bucket_prefix: Option<String>,
pub maybe_media_file_public_bucket_extension: Option<String>,
pub maybe_media_file_thumbnail_version: Option<u8>,
pub maybe_media_file_created_at: Option<DateTime<Utc>>,
pub maybe_assigned_worker: Option<String>,
pub maybe_assigned_cluster: Option<String>,
@@ -377,6 +383,8 @@ impl FromRow<'_, MySqlRow> for RawGenericInferenceJobStatus {
maybe_media_file_public_bucket_directory_hash: row.try_get("maybe_media_file_public_bucket_directory_hash")?,
maybe_media_file_public_bucket_prefix: row.try_get("maybe_media_file_public_bucket_prefix")?,
maybe_media_file_public_bucket_extension: row.try_get("maybe_media_file_public_bucket_extension")?,
maybe_media_file_thumbnail_version: row.try_get("maybe_media_file_thumbnail_version")?,
maybe_media_file_created_at: row.try_get("maybe_media_file_created_at")?,
maybe_assigned_worker: row.try_get("maybe_assigned_worker")?,
maybe_assigned_cluster: row.try_get("maybe_assigned_cluster")?,
maybe_frontend_failure_category: row.try_get("maybe_frontend_failure_category")?,
@@ -75,6 +75,8 @@ SELECT
media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,
media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,
media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,
media_files.maybe_thumbnail_version as `maybe_media_file_thumbnail_version?: u8`,
media_files.created_at as `maybe_media_file_created_at?: DateTime<Utc>`,
jobs.assigned_worker as maybe_assigned_worker,
jobs.assigned_cluster as maybe_assigned_cluster,
@@ -221,6 +223,8 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
public_bucket_location_or_hash: public_bucket_hash.to_string(),
maybe_media_file_public_bucket_prefix: record.maybe_media_file_public_bucket_prefix.clone(),
maybe_media_file_public_bucket_extension: record.maybe_media_file_public_bucket_extension.clone(),
maybe_media_file_thumbnail_version: record.maybe_media_file_thumbnail_version,
maybe_media_file_created_at: record.maybe_media_file_created_at,
public_bucket_location_is_hash: bucket_path_is_hash,
maybe_successfully_completed_at: record.maybe_successfully_completed_at,
}
@@ -293,6 +297,8 @@ struct RawGenericInferenceJobStatus {
pub maybe_media_file_public_bucket_directory_hash: Option<String>, // NB: This is the bucket directory hash
pub maybe_media_file_public_bucket_prefix: Option<String>,
pub maybe_media_file_public_bucket_extension: Option<String>,
pub maybe_media_file_thumbnail_version: Option<u8>,
pub maybe_media_file_created_at: Option<DateTime<Utc>>,
pub maybe_assigned_worker: Option<String>,
pub maybe_assigned_cluster: Option<String>,
@@ -79,6 +79,8 @@ pub struct ResultDetails {
pub public_bucket_location_or_hash: String,
pub maybe_media_file_public_bucket_prefix: Option<String>,
pub maybe_media_file_public_bucket_extension: Option<String>,
pub maybe_media_file_thumbnail_version: Option<u8>,
pub maybe_media_file_created_at: Option<DateTime<Utc>>,
/// Whether the location is a full path (for tts) or a hash (for vc) that
/// needs to be reconstructed into a path.
@@ -183,6 +183,8 @@ SELECT
media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,
media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,
media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,
media_files.maybe_thumbnail_version as maybe_media_file_thumbnail_version,
media_files.created_at as maybe_media_file_created_at,
jobs.assigned_worker as maybe_assigned_worker,
jobs.assigned_cluster as maybe_assigned_cluster,
@@ -312,6 +314,8 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
public_bucket_location_or_hash: public_bucket_hash.to_string(),
maybe_media_file_public_bucket_prefix: record.maybe_media_file_public_bucket_prefix.clone(),
maybe_media_file_public_bucket_extension: record.maybe_media_file_public_bucket_extension.clone(),
maybe_media_file_thumbnail_version: record.maybe_media_file_thumbnail_version,
maybe_media_file_created_at: record.maybe_media_file_created_at,
public_bucket_location_is_hash: bucket_path_is_hash,
maybe_successfully_completed_at: record.maybe_successfully_completed_at,
}
@@ -386,6 +390,8 @@ struct RawGenericInferenceJobStatus {
pub maybe_media_file_public_bucket_directory_hash: Option<String>, // NB: This is the bucket directory hash
pub maybe_media_file_public_bucket_prefix: Option<String>,
pub maybe_media_file_public_bucket_extension: Option<String>,
pub maybe_media_file_thumbnail_version: Option<u8>,
pub maybe_media_file_created_at: Option<DateTime<Utc>>,
pub maybe_assigned_worker: Option<String>,
pub maybe_assigned_cluster: Option<String>,
@@ -443,6 +449,8 @@ impl FromRow<'_, MySqlRow> for RawGenericInferenceJobStatus {
maybe_media_file_public_bucket_directory_hash: row.try_get("maybe_media_file_public_bucket_directory_hash")?,
maybe_media_file_public_bucket_prefix: row.try_get("maybe_media_file_public_bucket_prefix")?,
maybe_media_file_public_bucket_extension: row.try_get("maybe_media_file_public_bucket_extension")?,
maybe_media_file_thumbnail_version: row.try_get("maybe_media_file_thumbnail_version")?,
maybe_media_file_created_at: row.try_get("maybe_media_file_created_at")?,
maybe_assigned_worker: row.try_get("maybe_assigned_worker")?,
maybe_assigned_cluster: row.try_get("maybe_assigned_cluster")?,
maybe_frontend_failure_category: row.try_get("maybe_frontend_failure_category")?,
@@ -1,5 +1,6 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql, QueryBuilder};
use enums::by_table::media_files::media_file_class::MediaFileClass;
@@ -15,6 +16,9 @@ pub struct MediaFileThumbnailRow {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
/// Which video preview files exist. See `video_thumbnail_suffixes` in `bucket_paths`.
pub maybe_thumbnail_version: Option<u8>,
pub created_at: DateTime<Utc>,
}
pub struct BatchGetMediaFileThumbnailsByTokensArgs<'e, 'c, E>
@@ -49,7 +53,9 @@ where
media_type, \
public_bucket_directory_hash, \
maybe_public_bucket_prefix, \
maybe_public_bucket_extension \
maybe_public_bucket_extension, \
maybe_thumbnail_version, \
created_at \
FROM media_files \
WHERE user_deleted_at IS NULL \
AND mod_deleted_at IS NULL \
@@ -74,6 +80,8 @@ where
public_bucket_directory_hash: r.public_bucket_directory_hash,
maybe_public_bucket_prefix: r.maybe_public_bucket_prefix,
maybe_public_bucket_extension: r.maybe_public_bucket_extension,
maybe_thumbnail_version: r.maybe_thumbnail_version,
created_at: r.created_at,
}).collect())
}
@@ -88,4 +96,6 @@ struct RawRow {
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_thumbnail_version: Option<u8>,
created_at: DateTime<Utc>,
}
@@ -90,6 +90,7 @@ pub struct MediaFile {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
@@ -171,6 +172,7 @@ pub struct MediaFileRaw {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
//pub model_is_mod_approved: bool, // converted
//pub maybe_mod_user_token: Option<String>,
@@ -270,6 +272,7 @@ pub async fn batch_get_media_files(
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_thumbnail_version: record.maybe_thumbnail_version,
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,
@@ -336,6 +339,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
entity_stats.ratings_positive_count as maybe_ratings_positive_count,
entity_stats.ratings_negative_count as maybe_ratings_negative_count,
@@ -427,6 +431,7 @@ impl FromRow<'_, MySqlRow> for MediaFileRaw {
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
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")?,
@@ -42,6 +42,9 @@ pub struct MediaFilesByTokensRecord {
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
/// Which video preview files exist. See `video_thumbnail_suffixes` in `bucket_paths`.
pub maybe_thumbnail_version: Option<u8>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_username: Option<String>,
pub maybe_creator_display_name: Option<String>,
@@ -145,6 +148,7 @@ async fn get_raw_media_files_by_tokens(
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.is_user_upload,
m.is_intermediate_system_file,
@@ -211,6 +215,7 @@ async fn get_raw_media_files_by_tokens(
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.is_user_upload,
m.is_intermediate_system_file,
@@ -315,6 +320,7 @@ fn map_to_media_files(dataset:Vec<RawMediaFileJoinUser>) -> Vec<MediaFilesByToke
public_bucket_directory_hash: media_file.public_bucket_directory_hash,
maybe_public_bucket_prefix: media_file.maybe_public_bucket_prefix,
maybe_public_bucket_extension: media_file.maybe_public_bucket_extension,
maybe_thumbnail_version: media_file.maybe_thumbnail_version,
is_user_upload: i8_to_bool(media_file.is_user_upload),
is_intermediate_system_file: i8_to_bool(media_file.is_intermediate_system_file),
@@ -371,6 +377,7 @@ fn map_to_media_files(dataset:Vec<RawMediaFileJoinUser>) -> Vec<MediaFilesByToke
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
@@ -427,6 +434,7 @@ impl FromRow<'_, MySqlRow> for RawMediaFileJoinUser {
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
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")?,
@@ -96,6 +96,7 @@ pub struct MediaFile {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
@@ -184,6 +185,7 @@ pub struct MediaFileRaw {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
//pub model_is_mod_approved: bool, // converted
//pub maybe_mod_user_token: Option<String>,
@@ -296,6 +298,7 @@ pub async fn get_media_file_with_transactor(
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_thumbnail_version: record.maybe_thumbnail_version,
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,
@@ -373,6 +376,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
entity_stats.ratings_positive_count as maybe_ratings_positive_count,
entity_stats.ratings_negative_count as maybe_ratings_negative_count,
@@ -495,6 +499,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
entity_stats.ratings_positive_count as maybe_ratings_positive_count,
entity_stats.ratings_negative_count as maybe_ratings_negative_count,
@@ -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
}
@@ -66,6 +66,7 @@ pub struct MediaFile {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -114,6 +115,7 @@ pub struct MediaFileRaw {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -189,6 +191,7 @@ pub async fn list_batch_generated_redux_media_files_with_transactor(
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_thumbnail_version: record.maybe_thumbnail_version,
created_at: record.created_at,
updated_at: record.updated_at,
}
@@ -246,6 +249,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
m.created_at,
m.updated_at
@@ -337,6 +341,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
m.created_at,
m.updated_at
@@ -57,6 +57,7 @@ pub struct FeaturedMediaFileListItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_username: Option<String>,
@@ -145,6 +146,7 @@ pub async fn list_featured_media_files(args: ListFeaturedMediaFilesArgs<'_>) ->
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_thumbnail_version: record.maybe_thumbnail_version,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_username: record.maybe_creator_username,
maybe_creator_display_name: record.maybe_creator_display_name,
@@ -221,6 +223,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.maybe_creator_user_token,
u.username as maybe_creator_username,
@@ -463,6 +466,7 @@ struct MediaFileListItemInternal {
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_thumbnail_version: Option<u8>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_username: Option<String>,
@@ -526,6 +530,7 @@ impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
maybe_creator_user_token,
maybe_creator_username: row.try_get("maybe_creator_username")?,
maybe_creator_display_name: row.try_get("maybe_creator_display_name")?,
@@ -54,6 +54,7 @@ pub struct MediaFileListItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_username: Option<String>,
@@ -142,6 +143,7 @@ pub async fn list_media_files(args: ListMediaFilesArgs<'_>) -> AnyhowResult<Medi
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_thumbnail_version: record.maybe_thumbnail_version,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_username: record.maybe_creator_username,
maybe_creator_display_name: record.maybe_creator_display_name,
@@ -218,6 +220,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.maybe_creator_user_token,
u.username as maybe_creator_username,
@@ -443,6 +446,7 @@ struct MediaFileListItemInternal {
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_thumbnail_version: Option<u8>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_username: Option<String>,
@@ -506,6 +510,7 @@ impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
maybe_creator_user_token,
maybe_creator_username: row.try_get("maybe_creator_username")?,
maybe_creator_display_name: row.try_get("maybe_creator_display_name")?,
@@ -49,6 +49,7 @@ pub struct MediaFileListItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub creator_set_visibility: Visibility,
@@ -129,6 +130,7 @@ pub async fn list_media_files_by_batch_token(args: ListMediaFileByBatchArgs<'_>)
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_thumbnail_version: record.maybe_thumbnail_version,
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),
@@ -180,6 +182,7 @@ fn select_result_fields() -> String {
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.creator_set_visibility,
@@ -290,6 +293,7 @@ struct MediaFileListItemInternal {
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_thumbnail_version: Option<u8>,
creator_set_visibility: Visibility,
@@ -340,6 +344,7 @@ impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
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")?,
@@ -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,
@@ -206,6 +25,7 @@ fn select_result_fields() -> String {
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.maybe_prompt_token,
@@ -229,258 +49,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())
}
@@ -30,6 +30,7 @@ pub struct SessionMediaFileListItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_username: Option<String>,
@@ -141,6 +142,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
@@ -206,6 +208,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
@@ -31,6 +31,7 @@ pub struct ProjectMediaFileListItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_thumbnail_version: Option<u8>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_username: Option<String>,
@@ -148,6 +149,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
@@ -205,6 +207,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
@@ -274,6 +277,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
@@ -331,6 +335,7 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
@@ -32,6 +32,8 @@ pub struct MediaFileListRow {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
/// Which video preview files exist. See `video_thumbnail_suffixes` in `bucket_paths`.
pub maybe_thumbnail_version: Option<u8>,
// Cover image's bucket fields, from a LEFT JOIN on
// mf.maybe_cover_image_media_file_token. All three are Some-or-None
@@ -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,210 @@
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_thumbnail_version: Option<u8>,
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_thumbnail_version: Option<u8>,
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
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_thumbnail_version: record.maybe_thumbnail_version,
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,
}
}
}
@@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use sqlx::mysql::MySqlRow;
use sqlx::pool::PoolConnection;
use sqlx::{FromRow, MySql, QueryBuilder, Row};
@@ -16,6 +17,9 @@ pub struct BatchPromptContextItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
/// Which video preview files exist. See `video_thumbnail_suffixes` in `bucket_paths`.
pub maybe_thumbnail_version: Option<u8>,
pub created_at: DateTime<Utc>,
}
impl FromRow<'_, MySqlRow> for BatchPromptContextItem {
@@ -27,6 +31,8 @@ impl FromRow<'_, MySqlRow> for BatchPromptContextItem {
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_thumbnail_version: row.try_get("maybe_thumbnail_version")?,
created_at: row.try_get("created_at")?,
})
}
}
@@ -49,7 +55,9 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.created_at
FROM prompt_context_items pci
JOIN media_files m ON pci.media_token = m.token
@@ -14,6 +14,9 @@ pub struct PromptContextItem {
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
/// Which video preview files exist. See `video_thumbnail_suffixes` in `bucket_paths`.
pub maybe_thumbnail_version: Option<u8>,
pub created_at: DateTime<Utc>,
}
struct RawPromptContextItem {
@@ -22,6 +25,8 @@ struct RawPromptContextItem {
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_thumbnail_version: Option<u8>,
created_at: DateTime<Utc>,
}
pub async fn list_prompt_context_items(
@@ -37,7 +42,9 @@ SELECT
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
m.created_at as `created_at: DateTime<Utc>`
FROM prompt_context_items pci
JOIN media_files m
@@ -61,6 +68,8 @@ ORDER BY pci.id ASC
public_bucket_directory_hash: item.public_bucket_directory_hash,
maybe_public_bucket_prefix: item.maybe_public_bucket_prefix,
maybe_public_bucket_extension: item.maybe_public_bucket_extension,
maybe_thumbnail_version: item.maybe_thumbnail_version,
created_at: item.created_at,
}
}).collect::<Vec<PromptContextItem>>())
}
@@ -61,6 +61,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -54,6 +54,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -54,6 +54,7 @@ SELECT
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
@@ -60,6 +60,8 @@ fn select_result_fields() -> &'static str {
media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_hash,
media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,
media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,
media_files.maybe_thumbnail_version as maybe_media_file_thumbnail_version,
media_files.created_at as maybe_media_file_created_at,
media_file_creator.token as maybe_media_file_creator_user_token,
media_file_creator.username as maybe_media_file_creator_username,
media_file_creator.display_name as maybe_media_file_creator_display_name,
@@ -22,6 +22,8 @@ pub struct UserBookmark {
pub maybe_media_file_public_bucket_hash: Option<String>,
pub maybe_media_file_public_bucket_prefix: Option<String>,
pub maybe_media_file_public_bucket_extension: Option<String>,
pub maybe_media_file_thumbnail_version: Option<u8>,
pub maybe_media_file_created_at: Option<DateTime<Utc>>,
pub maybe_media_file_creator_user_token: Option<UserToken>,
pub maybe_media_file_creator_username: Option<String>,
@@ -108,6 +110,8 @@ pub struct RawUserBookmarkRecord {
pub(crate) maybe_media_file_public_bucket_hash: Option<String>,
pub(crate) maybe_media_file_public_bucket_prefix: Option<String>,
pub(crate) maybe_media_file_public_bucket_extension: Option<String>,
pub(crate) maybe_media_file_thumbnail_version: Option<u8>,
pub(crate) maybe_media_file_created_at: Option<DateTime<Utc>>,
pub(crate) maybe_media_file_cover_image_public_bucket_hash: Option<String>,
pub(crate) maybe_media_file_cover_image_public_bucket_prefix: Option<String>,
@@ -148,6 +152,8 @@ impl RawUserBookmarkRecord {
maybe_media_file_public_bucket_hash: self.maybe_media_file_public_bucket_hash,
maybe_media_file_public_bucket_prefix: self.maybe_media_file_public_bucket_prefix,
maybe_media_file_public_bucket_extension: self.maybe_media_file_public_bucket_extension,
maybe_media_file_thumbnail_version: self.maybe_media_file_thumbnail_version,
maybe_media_file_created_at: self.maybe_media_file_created_at,
maybe_media_file_creator_user_token: self.maybe_media_file_creator_user_token,
maybe_media_file_creator_username: self.maybe_media_file_creator_username,
maybe_media_file_creator_display_name: self.maybe_media_file_creator_display_name,
@@ -248,6 +254,8 @@ impl FromRow<'_, MySqlRow> for RawUserBookmarkRecord {
maybe_media_file_public_bucket_hash: row.try_get("maybe_media_file_public_bucket_hash")?,
maybe_media_file_public_bucket_prefix: row.try_get("maybe_media_file_public_bucket_prefix")?,
maybe_media_file_public_bucket_extension: row.try_get("maybe_media_file_public_bucket_extension")?,
maybe_media_file_thumbnail_version: row.try_get("maybe_media_file_thumbnail_version")?,
maybe_media_file_created_at: row.try_get("maybe_media_file_created_at")?,
maybe_media_file_cover_image_public_bucket_hash: row.try_get("maybe_media_file_cover_image_public_bucket_hash")?,
maybe_media_file_cover_image_public_bucket_prefix: row.try_get("maybe_media_file_cover_image_public_bucket_prefix")?,
maybe_media_file_cover_image_public_bucket_extension: row.try_get("maybe_media_file_cover_image_public_bucket_extension")?,
@@ -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()
}
@@ -6,9 +6,10 @@ use log::{error, info};
use tempdir::TempDir;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use bucket_paths::path_conventions::video_thumbnail_suffixes::{CURRENT_VIDEO_THUMBNAIL_VERSION, VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX, VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX};
use bucket_paths::path_conventions::video_thumbnail_suffixes::{CURRENT_VIDEO_THUMBNAIL_VERSION, VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX, VIDEO_ANIMATED_WEBP_THUMBNAIL_SUFFIX, VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX};
use ffmpeg_utils::ffmpeg::ffmpeg_video_first_frame_to_jpg_thumbnail::{ffmpeg_video_first_frame_to_jpg_thumbnail, FfmpegVideoFirstFrameToJpgThumbnailArgs};
use ffmpeg_utils::ffmpeg::ffmpeg_video_gif_preview::{ffmpeg_video_gif_preview, FfmpegVideoGifPreviewArgs};
use ffmpeg_utils::ffmpeg::ffmpeg_video_webp_preview::{ffmpeg_video_webp_preview, FfmpegVideoWebpPreviewArgs};
use mysql_queries::queries::media_files::thumbnails::list_video_media_files_without_thumbnails_for_job::VideoMediaFileWithoutThumbnail;
use mysql_queries::queries::media_files::thumbnails::update_video_media_file_with_thumbnail::update_video_media_file_with_thumbnail;
@@ -24,6 +25,10 @@ pub struct DownloadedFile {
}
/// Download the source video from the bucket, generate thumbnails, and upload them.
///
/// Writes thumbnail version 2 (see `video_thumbnail_suffixes`): a first-frame
/// jpg, an animated webp, and an animated gif. The gif is a dual write kept for
/// ArtCraft desktop on macOS Catalina, which can't render animated webp.
pub async fn process_single_media_file(
deps: &JobDependencies,
media_file: &VideoMediaFileWithoutThumbnail,
@@ -91,7 +96,7 @@ pub async fn process_single_media_file(
info!("Uploaded JPG thumbnail to {}", jpg_object_path);
// Generate gif thumbnail
// Generate gif thumbnail (dual write for macOS Catalina; see `video_thumbnail_suffixes`)
let gif_path = downloaded.temp_dir.path().join("thumbnail.gif");
let stage_started_at = Instant::now();
@@ -123,6 +128,39 @@ pub async fn process_single_media_file(
info!("Uploaded GIF preview to {}", gif_object_path);
// Generate webp thumbnail
let webp_path = downloaded.temp_dir.path().join("thumbnail.webp");
let stage_started_at = Instant::now();
let source = Arc::clone(&downloaded);
let destination = webp_path.clone();
let result = tokio::task::spawn_blocking(move || {
ffmpeg_video_webp_preview(FfmpegVideoWebpPreviewArgs {
input_video_path: &source.file_path,
output_webp_path: &destination,
})
}).await.map_err(anyhow::Error::from).and_then(|result| result);
timing.log_stage("webp_generation", stage_started_at, result.is_ok());
if let Err(err) = result {
error!("Failed to generate WebP preview for {}: {:?}", media_file.token.as_str(), err);
return alert_pager_and_return_err(&deps.pager, "WebP preview generation failed", err.into());
}
info!("Generated WebP preview for {}", media_file.token.as_str());
let webp_object_path = format!("{video_object_path}{VIDEO_ANIMATED_WEBP_THUMBNAIL_SUFFIX}");
let stage_started_at = Instant::now();
// NB: Explicit content type; the jpg and gif uploads above use the bucket default.
let result = deps.public_bucket_client.upload_filename_with_content_type(&webp_object_path, &webp_path, "image/webp").await;
timing.log_stage("webp_upload", stage_started_at, result.is_ok());
if let Err(err) = result {
error!("Failed to upload WebP preview for {}: {:?}", media_file.token.as_str(), err);
return alert_pager_and_return_err(&deps.pager, "WebP preview upload failed", err);
}
info!("Uploaded WebP preview to {}", webp_object_path);
info!("Marking thumbnail job for {:?} done", media_file.token);
// Mark the media file as having a thumbnail in the database.
@@ -21,6 +21,7 @@ use cloud_storage::legacy_bucket_client::LegacyBucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use errors::AnyhowResult;
use ffmpeg_utils::ffmpeg::webp_ffmpeg_binary::resolve_webp_ffmpeg;
use jobs_common::job_stats::JobStats;
use server_environment::ServerEnvironment;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
@@ -74,6 +75,11 @@ async fn main() -> AnyhowResult<()> {
)
.ok_or(anyhow!("invalid server environment"))?;
// Thumbnail version 2 writes animated webp previews; fail fast if no ffmpeg can.
// (In development on macOS, this may fall back to Homebrew's ffmpeg-full.)
let webp_ffmpeg = resolve_webp_ffmpeg(server_environment.is_development())?;
info!("Webp previews will use ffmpeg at: {}", webp_ffmpeg.display());
// Bucket setup
let access_key = easyenv::get_env_string_required(ENV_ACCESS_KEY)?;
let secret_key = easyenv::get_env_string_required(ENV_SECRET_KEY)?;
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more