Compare commits

...
50 Commits
Author SHA1 Message Date
BFlatandClaude Opus 5.5 3048db996d Bump FigCraft downloads to 0.5.1 (#137)
Points the FigCraft download buttons on `/apps/figcraft` at
[v0.5.1](https://github.com/storytold/figcraft/releases/tag/v0.5.1) (was
0.4.0). The release ships the same file set, so only the version
changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:44:23 +07:00
BFlatandClaude Opus 5.5 bcff742052 Add FigCraft to the Crafting Apps pages (#135)
## Summary

Adds FigCraft (interface design) as the 13th Crafting App, after
DesignCraft.

- **Catalog**: new `figcraft` entry in `crafting-apps.ts` (copy adapted
from the repo README, no third-party product names), release `0.4.0`,
accent colour from the app icon.
- **Assets**: icon, share card and the seven README screenshots from
`storytold/figcraft` main, plus a poster frame for the video.
- **Video section**: an optional `demo` on an app entry renders a
section directly under the hero. FigCraft uses it for the live-sessions
collaboration clip (muted, looping, plays in view via `LazyVideo`, which
gains a `poster` prop). Later sections renumber when a demo is present.
- **/apps hub**: coverage line mentions interface design, the title
keeps "One craft." on one line, and the icon row is a grid of two even
rows (7 + 6) from `sm` up.

## Testing

Checked `/apps` and `/apps/figcraft` in `next dev` at desktop width,
dark theme: video plays, section indices run 01–06, no broken images.
`tsc` reports no errors in the touched files. Not checked: production
build, light theme, mobile layout beyond the icon row.

## Not included

The launcher's release-polling list
(`poll_craft_app_releases_thread/config.rs`) does not list FigCraft yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:37:13 +07:00
Brandon ThomasandClaude Opus 5.5 854869ba53 Coordinate Craft App release polling across the fleet through Redis (#77)
New releases took up to ~30 min to reach \`GET
/v1/craft_apps/release_info\` (ArtCraft Launcher 0.2.0 needed 17–29
min), pods disagreed with each other, and every pod polled GitHub on its
own, which uses up the unauthenticated quota (60/h per IP, and 304s
count) and risks backing off for up to 4 h.

The pods now share their results through Redis. There's no lock:
- **One Redis hash per app** (\`craftApps:releases:app:<key>\`, 7 d
TTL). It holds the latest release, the ETag and \`fetched_ms\`. Before
asking GitHub about an app, a pod reads its entry. If it's fresher than
\`interval − 30 s\` (a clock-skew margin), the pod takes it from Redis
and doesn't call GitHub.
- **Last write wins, guarded by timestamp.** A Lua script never lets an
entry go back in time. A 304 moves only \`fetched_ms\`, and only while
the stored ETag still matches.
- **A soft per-app claim** (60 s, no owner, it just lapses), so a fleet
that ticks every 30 s rarely fetches the same app twice.
- **Backoff shared by the whole fleet**
(\`craftApps:releases:backoff\`), so a rate limit slows everyone down.
Per-app failure retry is 60 s, doubling.
- **The endpoint is unchanged.** It still serves each pod's in-memory
copy. If Redis is down, a pod falls back to polling on its own and warns
at most every 10 minutes.

**The GitHub token is optional.** With
\`CRAFT_APPS_RELEASE_GITHUB_TOKEN\` set, the default interval is 120 s
(min 60). Without it (unset or blank), calls go out unauthenticated with
no Authorization header, at 1800 s (min 300), and startup and polling
are never blocked. \`CRAFT_APPS_RELEASE_COORDINATION_ENABLED=false\`
switches back to the old per-pod polling.

**Tests:** \`cargo test -p storyteller-web --bin storyteller-web craft\`
(49 pass) and \`cargo test -p redis_schema craft\` (4 pass). The Redis
tests passed against a local redis-server with
\`CRAFT_APPS_TEST_REDIS_URL=… -- --include-ignored --skip live_round\`
(51 pass).

**Follow-ups after review:**
- **The poller has its own Redis pool** (2 connections; connects within
3 s; every read and write, including the pool's `PING` check, times out
after 5 s). Before this, it shared the handlers' pool, which has no read
timeout, so a Redis that stopped answering could leave the poller
holding every connection while handlers waited 30 s for one. A test
against a fake Redis that goes silent after the handshake fails without
the timeouts and passes with them.
- **When Redis won't run scripts**, writes fall back to
`WATCH`/`MULTI`/`EXEC`: read and decode the guarding fields, decide, and
write in a transaction that Redis drops if another pod wrote in between.
This switch only happens when Redis refuses the script itself. Being
unreachable, busy, out of memory or read-only doesn't trigger it. It was
tested against a real Redis user denied `@scripting`.
- **New pods load from Redis at startup**, before the startup jitter, so
they serve the fleet's releases right away without asking GitHub.
- **`Cache-Control: no-store` until every app has been fetched**
(`last_fetched` set), so Cloudflare never caches a cold pod's "not
fetched yet" answer. The response body is unchanged.

**Tests:** `cargo test -p storyteller-web --bin storyteller-web craft --
--include-ignored --skip live_round` against a local Redis 8.6: 63 pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:05:06 -04:00
Navaras P 25f38b79b8 Add docker-compose setup for self-hosting the backend (#68)
Closes storytold/artcraft#1301

Adds `build/selfhost/`, a Docker Compose setup that runs
`storyteller-web` with MySQL 8.4, Redis, Elasticsearch and SeaweedFS
(S3-compatible storage).

- A `migrate` service applies `_database/sql/migrations` in order and
records them in `__diesel_schema_migrations`, so reruns only apply new
migrations.
- `storage-init` creates the three buckets the server expects.
- The server image is built from `build/service_cpu.Dockerfile`.
- `.env.example` lists every variable the server requires at startup,
with placeholders for third-party providers.

SeaweedFS is used because MinIO no longer publishes Docker images.

**Tested locally (Docker Desktop on Windows):** image build, all 285
migrations (plus a rerun), `GET /_status` healthy, model listing,
account creation and login, and an image upload stored in the bucket.

**Not included:** background workers, Elasticsearch index setup.
2026-10-11 18:51:50 -04:00
Dylan M. Taylor 2893b6f713 chore: bump github actions to fix Node 20 deprecation warnings (#69)
This PR bumps GitHub Actions to their latest versions and pins them to
strict SHAs, remediating Node 20 deprecation warnings.
2026-10-11 18:48:57 -04:00
Dylan M. Taylor ad1ca86b00 Update launcher version (#85)
Fixes link to point to the new version with fixes and improvements
2026-10-11 18:34:45 -04:00
Dylan M. Taylor 165a4b272c Add WordCraft, SoundCraft, DeckCraft, CADCraft, and GridCraft marketing pages (#79)
## Summary

Adds product definitions and marketing page assets for the five
remaining Crafting Apps (WordCraft, SoundCraft, DeckCraft, CADCraft, and
GridCraft), and bumps all Crafting Apps in `crafting-app-releases.ts` to
their latest published GitHub release versions.

### Changes

- **Data & Definitions (`crafting-apps.ts`,
`crafting-app-releases.ts`)**:
- Added `wordcraft`, `soundcraft`, `deckcraft`, `cadcraft`, and
`gridcraft` to `CraftAppSlug` and `CRAFTING_APPS`.
  - Added feature sets, ledes, pitches, and release entries.
  - Bumped all app releases to latest GitHub releases:
    - `photocraft`: `0.7.0`
    - `vectorcraft`: `0.9.0`
    - `filmcraft`: `0.6.0`
    - `lightcraft`: `0.6.0`
    - `pdfcraft`: `0.6.0`
    - `effectcraft`: `0.8.0`
    - `designcraft`: `0.6.0`
    - `wordcraft`: `0.5.0`
    - `soundcraft`: `0.5.0`
    - `deckcraft`: `0.5.0`
    - `cadcraft`: `0.5.0`
    - `gridcraft`: `0.5.0`
- Extended `COUNT_WORDS` to support twelve apps and updated category
coverage on `/apps`.
- Maintained copy rules: accurately describing categories and workflows
without naming third-party products.
- **Branding & Themes (`globals.css`)**:
- Added theme color identities (`--app`, `--app-ink`) for all five apps
across light and dark themes with full AA contrast compliance.
- **Visual Assets (`public/images/apps/<slug>/`)**:
- Generated 256px WebP icons, 1600x1000 WebP screenshots, and 1200x630
OG share cards for each new app.

### Testing

- `npm run typecheck` (`tsc --noEmit`) passes cleanly.
- `npm run build` generates all 12 app pages statically (44/44 static
pages).
2026-10-11 13:09:48 -04:00
Dylan M. Taylor b45df084b8 feat(website): offer format choices for ArtCraft Launcher on Linux (#83)
### Summary

- **Linux format choices**: Added a split dropdown button to
`LauncherDownloadButton` on Linux, allowing users to choose their
preferred package format directly from the download button (Flatpak,
AppImage, Debian package, and RPM package).
- **Default and ordering**: Flatpak is selected by default for the
primary download action, with AppImage, `.deb`, and `.rpm` available via
the dropdown arrow.
- **Page integration**: Passed complete release download lists to
`LauncherDownloadButton` across Crafting Apps pages so all supported
Linux formats are available.
2026-10-11 13:03:11 -04:00
BFlatandClaude Opus 5.5 28bd2e7ee3 Bump ArtCraft Launcher downloads to 0.2.1 (#81)
Points every launcher download on /apps and the Crafting App pages at
[v0.2.1](https://github.com/storytold/craft-launcher/releases/tag/v0.2.1).
That release fixes macOS installs and updates failing with "couldn't
open the disk image".

This is merged before the release is published. It only goes live when
the site is published on Netlify.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:53:27 +07:00
BFlatandClaude Opus 5.5 02a3de2509 Bump ArtCraft Launcher downloads to 0.2.0 (#76)
Points every launcher download on /apps and the Crafting App pages at
[v0.2.0](https://github.com/storytold/craft-launcher/releases/tag/v0.2.0),
the latest release. Checked: all 20 files the site links exist in v0.2.0
and download (HTTP 200).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:40:40 +07:00
DWAA1660 4ae6fe7966 fix(website): update VectorCraft downloads to v0.7.0 (#64)
The VectorCraft app page currently advertises v0.6.0, while GitHub's
latest published release is
[v0.7.0](https://github.com/storytold/vectorcraft/releases/tag/v0.7.0).
Update its shared release configuration so the visible version, release
page, and all 21 asset download links use v0.7.0.

Audited all eight advertised apps against GitHub's latest published
releases on October 9, 2026. ArtCraft (0.41.0), PhotoCraft (0.5.0),
FilmCraft (0.4.0), LightCraft (0.4.0), PDFCraft (0.4.0), EffectCraft
(0.6.0), and DesignCraft (0.4.0) are already current. Both ArtCraft
download configurations match the latest release; the static desktop
landing page delegates to the main download page.

Validation: all 151 configured active download URLs match nonempty
published assets in the latest releases, including all 21 VectorCraft
v0.7.0 assets; `npm run typecheck` passes in
`frontend/apps/artcraft-website-next`; `git diff --check` passes.

The website requires deployment after merging before its public download
links change.
2026-10-11 00:09:26 +07:00
Brandon ThomasandClaude Opus 5.5 f37f82b400 Serve Craft App release info for ArtCraft Launcher (#71)
GET /v1/craft_apps/release_info reports the latest release of every
Craft App (and ArtCraft Launcher itself), so launchers stop polling
GitHub directly. A background thread polls GitHub and keeps a shared
cache.

Polling (threads/poll_craft_app_releases_thread):
- One round at startup (after 0–30 s of jitter, so a fleet deploy is
spread out), then every CRAFT_APPS_RELEASE_POLL_INTERVAL_SECS (30 min
default, 5 min minimum) with ±10% jitter
- One request per app (GET /repos/{repo}/releases/latest) with ETag
revalidation and 1 s between requests
- Rate limits stop the round and wait for GitHub's reset, backing off
2×, 4×, … the interval up to CRAFT_APPS_RELEASE_MAX_BACKOFF_SECS (4 h);
rounds where every request failed retry after 1, 2, 4… min, never more
often, up to the interval
- Per app all-or-nothing: either its release and app_last_fetched are
stored, or nothing about it changes
- CRAFT_APPS_RELEASE_POLLING_ENABLED, CRAFT_APPS_RELEASE_GITHUB_TOKEN
and CRAFT_APPS_RELEASE_APPS (key=Name=org/repo,…) configure it

Cache (state/craft_apps_release_cache): Arc<RwLock<BTreeMap<..>>> of
typed entries. release_info.last_fetched is the oldest app_last_fetched,
absent until every app has been fetched. Apps are listed from the start,
without a release or app_last_fetched until fetched.

The endpoint never fails, sets Cache-Control: public, max-age=60, and
returns empty app_renames, repo_renames and important_notices for now.
API types live in artcraft_api_defs::craft_apps::release_info, with each
asset's OS / arch / package type parsed from its file name. reqwest
moves from a dev-dependency to a dependency of storyteller-web.

Tests: cache freshness, asset-name parsing for every published file
type, untrusted-answer filtering, backoff, and an ignored live round
against GitHub that also writes a sample the launcher's parser is tested
against.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:35:45 -04:00
BFlatandClaude Opus 5.5 42b1872760 Bump ArtCraft Launcher downloads to 0.1.2 (#74)
Points every launcher download on /apps and the Crafting App pages at
[v0.1.2](https://github.com/storytold/craft-launcher/releases/tag/v0.1.2):
the Windows installer now lets you pick the install folder and offers a
desktop shortcut. Merged ahead of the release being published; it goes
live only when the site is published on Netlify.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:44:31 +07:00
BFlatandClaude Opus 5.5 a93ebd6f84 Bump ArtCraft Launcher downloads to 0.1.1 (#73)
Points every launcher download on /apps and the Crafting App pages at
[v0.1.1](https://github.com/storytold/craft-launcher/releases/tag/v0.1.1),
now the latest release: the restyled launcher, the Windows setup wizard
and the blue app icon.

Checked: all 20 files the site links exist in v0.1.1 and download (HTTP
200).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:04:32 +07:00
BFlatandClaude Opus 5.5 b57f9155da Lead Crafting App pages with the Launcher download; shorter download labels (#72)
## What

- **App pages lead with ArtCraft Launcher.** The first download button
at the top of every `/apps/<slug>` page now downloads ArtCraft Launcher
for the visitor's system. It's the same OS-aware button as the `/apps`
hub, with the platform and build underneath. With no detected system it
jumps to `#get-it`. If the launcher ever has no release, the hero falls
back to the app's own download.
- **Shorter labels.**
- The launcher button now reads **Download Launcher** (hub, app heroes
and the Get-it callout).
- The standalone platform buttons read **Standalone for macOS / Windows
/ Linux** instead of "Download PhotoCraft for Windows". The section
heading and each "Other PhotoCraft files" list still name the app.
- **Spacing fix.** The "For Windows · Installer (64-bit)" line under the
launcher button no longer widens its column, which used to push the
buttons beside it away.

The launcher stays at v0.1.0, its latest published release. All 20 links
the site builds for it exist in that release. v0.1.1 is built but still
a draft; bump `CRAFT_LAUNCHER_RELEASE` once it's published.

## Checks

- Ran the site locally (`next dev`) and looked at `/apps/photocraft` and
`/apps` in Edge at desktop and phone widths: one row of buttons, even
gaps, and the launcher link goes to the Windows x64 MSI.
- `npm run typecheck` reports the same 54 errors as `main` without this
change (Playwright and three.js types in `tests/` and
`media-viewer-3d.tsx`); none come from these files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:00:18 +07:00
BFlatandClaude c762161646 Recommend ArtCraft Launcher on the Crafting Apps pages (#70)
## Summary

Pushes ArtCraft Launcher
([storytold/craft-launcher](https://github.com/storytold/craft-launcher))
as the recommended way to install the Crafting Apps. One install covers
every app and keeps them up to date. Each app keeps its own installers.

- **`/apps` header:** the main button is now **Download ArtCraft
Launcher**. It detects the visitor's OS and links straight to the right
installer, with a small line naming the file (e.g. "For macOS · Disk
image (Universal)"). Below the buttons, one line says it's the
recommended option and that each app also has its own installers.
- **`/apps/<slug>` Get-it section:** split into two numbered options.
**Option 1 · Recommended** is the launcher block: the pitch on the left,
and on the right a sunken panel with the version, a full-width download
button and an About link. **Option 2 · Standalone** ("Just <App>?
Download it standalone.") comes next, followed by the app's existing
per-platform installers, unchanged.
- **Fallback:** when the OS can't be detected (phones, tablets), the
launcher buttons go to the launcher's GitHub release page.

## Implementation

- `CRAFT_LAUNCHER_RELEASE` in `crafting-app-releases.ts` holds the
launcher's version (v0.1.0). It reuses the shared asset list minus the
macOS CLI zip, and its files are named `artcraft-launcher-*`. Setting it
to `null` hides every launcher button.
- `src/lib/craft-launcher.ts` holds the name, repo, download list and
release-page URL. `src/components/apps/launcher-callout.tsx` holds the
app-page block.
- `craftReleaseDownloads` now delegates to a new repo-based
`releaseDownloads` helper, which the launcher also uses.
- `LauncherDownloadButton` in `app-downloads.tsx` reuses the existing OS
and CPU detection.
- Download clicks are already tracked as `app_download` (`app_name:
craft-launcher`) by the link classifier in `analytics.ts`.
- Small fix: the platform cards get `min-w-0`, so long filenames no
longer push the page wider than the screen on narrow phones.

## Testing

- `npm run typecheck` and `npm run build` pass.
- All 20 launcher release URLs on the page return 200 on GitHub.
- Checked in Chromium with a macOS user agent: `/apps` and app pages in
light and dark mode, and at 1440px, 390px and 360px. Nothing overflows
the page horizontally.

This needs a deploy of `artcraft-website-next` before getartcraft.com
changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 12:56:10 +07:00
Brandon ThomasandClaude Opus 5.5 c489e8c650 Cap MySQL statement cache and connection lifetime in storyteller-web (#65)
## Problem

Production `storyteller-web` and the job workers started returning fast
500s with MySQL error 1461:

```
Can't create more than max_prepared_stmt_count statements (current value: 16382)
```

A customer saw this on `GET /v1/prompt/batch`. Errors rose to ~316/hour
on 2026-10-09 and stopped after a rolling restart at ~17:50 UTC. The
restart only clears the caches, so this will happen again under load.

## Cause

sqlx (0.7.4) caches up to **100** prepared statements per connection by
default, and each cached statement stays open on the server. At current
scale, `storyteller-web` can hold:

| Setting                       | Before   | After   |
|-------------------------------|----------|---------|
| Replicas                      | 7        | 7       |
| `MYSQL_MAX_CONNECTIONS`       | 50       | 50      |
| Statement cache / connection  | 100      | 25      |
| Max statements held           | ~35,000  | ~8,750  |
| Server cap                    | 16,382   | 16,382  |

The ~70 call sites that use `QueryBuilder` (variable-length `IN (...)`
lists) produce many different SQL strings, which fill the caches faster.

## Changes

`crates/service/web/storyteller_web/src/configs/connect_to_database.rs`:

- **`statement_cache_capacity`** defaults to 25, configurable with
`MYSQL_STATEMENT_CACHE_CAPACITY`.
- **`max_lifetime`** defaults to 10 minutes (sqlx's default is 30),
configurable with `MYSQL_MAX_LIFETIME_SECONDS`. A query cancelled
between prepare and close (e.g. the client disconnects) leaves its
statement open on the server until the connection closes. Recycling
connections sooner frees those statements sooner.
- Both values are logged with the existing pool settings at startup.

Job workers are left unchanged: their pools are hardcoded to 2
connections, so each holds at most 200 statements.

## Notes

- If replicas or `MYSQL_MAX_CONNECTIONS` increase, keep `replicas ×
MYSQL_MAX_CONNECTIONS × MYSQL_STATEMENT_CACHE_CAPACITY` well under
16,382.
- Follow-up (not in this PR): use `.persistent(false)` on hot dynamic
`QueryBuilder` queries such as `batch_get_prompts`.

## Testing

- `SQLX_OFFLINE=true cargo check -p storyteller-web` passes.
- After deploy, look for the new values in the `Building MySQL pool:`
startup log line, and confirm no new `max_prepared_stmt_count` errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:20 -04:00
Brandon Thomas 17373314ae remove old website (#63) 2026-10-09 11:31:28 -04:00
931a8b925c Bump Crafting App downloads to the new releases; add ARM64, Flatpak and FreeBSD (#61)
## What

Moves every `/apps/<app>` page on getartcraft.com (Next.js site) to the
app's latest GitHub release, and adds the builds every release now
ships.

| App | Site | Latest release |
|---|---|---|
| PhotoCraft | 0.3.0 → **0.5.0** | v0.5.0 |
| VectorCraft | 0.5.0 → **0.6.0** | v0.6.0 |
| EffectCraft | 0.4.0 → **0.6.0** | v0.6.0 |
| FilmCraft, LightCraft, PdfCraft, DesignCraft | 0.2.1 → **0.4.0** |
v0.4.0 |

New files in every app's list (all seven releases ship them now):
- **Windows:** Installer (ARM64), Portable (ARM64), previously only
PhotoCraft, VectorCraft and EffectCraft
- **Linux:** Flatpak (x86_64), Flatpak (ARM64)
- **Other:** FreeBSD (x86_64)

`WITH_WINDOWS_ARM64` and `CRAFTING_APPS_LATEST_VERSION` are gone: every
app ships the same file set, and versions differ per app. PdfCraft's
`fileSlug: "printcraft"` override is dropped, since 0.4.0 files are
named `pdfcraft-…`. Only the seven classic apps are on the site; the
newer apps aren't touched.

## Verification
- Every one of the 21 links per app matches a file in that app's
published (non-draft, non-prerelease) release. The only release files
not linked are the two `.AppImage.zsync` update files.
- `npm run typecheck` passes.
- `next dev`: `/apps/photocraft`, `/apps/pdfcraft` and
`/apps/effectcraft` render the new links (Windows ARM64, both Flatpaks,
FreeBSD); no stale version or `printcraft` URLs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:29 -04:00
BFlatandClaude Opus 5.5 0e1991de6d Bump VectorCraft downloads to 0.5.0 (#58)
## What

Moves VectorCraft's `/apps/vectorcraft` downloads from 0.4.0 to
[v0.5.0](https://github.com/storytold/vectorcraft/releases/tag/v0.5.0).
The other Crafting Apps are already on their latest releases:

| App | Site | Latest release |
|---|---|---|
| VectorCraft | 0.4.0 → **0.5.0** | v0.5.0 |
| PhotoCraft | 0.3.0 | v0.3.0 |
| EffectCraft | 0.4.0 | v0.4.0 |
| FilmCraft, LightCraft, PdfCraft, DesignCraft | 0.2.1 | v0.2.1 |

## Verification
- All 19 files the site lists, including the Windows on ARM builds, are
in v0.5.0, and each download URL resolves. The release page returns 200.
- The release also ships a FreeBSD tarball, which the site doesn't list
for any app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:18:42 +07:00
BFlatandClaude Opus 5.5 d950e57352 Rename PrintCraft to PdfCraft on the Crafting Apps pages (#57)
## What

The `storytold/printcraft` repo is now
[`storytold/pdfcraft`](https://github.com/storytold/pdfcraft), and every
Crafting Apps README already links `getartcraft.com/apps/pdfcraft`,
which didn't exist on the site until now. This renames the app on the
site.

- **Slug and name:** `/apps/pdfcraft`, shown as "PdfCraft" in the nav,
footer, `/apps` hub, sitemap, press kit and JSON-LD. Copy, category and
theme colour are unchanged; only the name changes.
- **Legacy links redirect permanently (308):**
  - `/apps/printcraft` → `/apps/pdfcraft`
- `/images/apps/printcraft/<file>` → `/images/apps/pdfcraft/<file>`, so
share images already cached by social sites keep loading.
- **Images:** moved to `public/images/apps/pdfcraft/`. The four
screenshots and the share image were rebuilt from the README's renamed
`pdfcraft-*.png` renders, which are slightly newer than the site's (they
show the in-app Discord button). The "Full size" and press-kit links had
been pointing at the old `printcraft-*.png` names, which no longer
exist; they now point at the new files.
- **Downloads:** v0.2.1 was published before the rename, so its files
are still called `printcraft-0.2.1-*`. `CraftRelease` gains an optional
`fileSlug` for this, and PdfCraft sets it to `printcraft`. Once a
release ships with `pdfcraft-*` names, removing that one line switches
it over.

## Verification
Rendered locally:
- `/apps/printcraft` and the old image URLs return 308 to their PdfCraft
equivalents. Following the redirect lands on the PdfCraft page with its
theme.
- `/apps/pdfcraft`: the title, canonical URL and `og:image` use
pdfcraft. All 17 download links resolve directly on
`storytold/pdfcraft`, without going through GitHub's redirect.
- The sitemap lists `/apps/pdfcraft`, and `/`, `/apps` and `/press-kit`
show no "PrintCraft". All press-kit links for PdfCraft return 200.
- `tsc` shows no errors in the changed files. The errors that remain are
already on `main`.

## Note
The screenshots still show "PrintCraft" inside the app (the window tab
and the sample PDF), because the PdfCraft repo renamed the image files
without re-rendering them. They'll update once the README screenshots
are re-captured.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:56:47 +07:00
BFlatandClaude Opus 5.5 652146ad96 Refresh VectorCraft screenshots and bump Crafting App downloads (#56)
## What

### VectorCraft screenshots
The images were regenerated from the latest renders in VectorCraft's
README
([storytold/vectorcraft@1c3b7b1](https://github.com/storytold/vectorcraft/commit/1c3b7b16)).
The site's previous hero still showed the old DrawCraft UI.

- `hero`, `ribbons`, `bezier` and `sheet` replaced, and a new
`perspective` shot added for the Perspective Grid. They follow the
README's order.
- Captions and alt text updated to match what each new shot shows.
- `og.jpg` share image rebuilt from the new hero, composed the same way
as before: a centred crop with a 144px icon in the bottom-left corner.
- Encoding matches the existing files (1600×1000 WebP q82, JPEG q85), so
the files are about the same size, 50–94 KB each.

### Gallery layout
`AppGallery` used to show the first shot after the hero at full width
and pair the rest. With an even number of shots, that left an empty cell
at the end. Now the first shot only goes full width when the count is
odd. VectorCraft's four shots form a 2×2 grid, like the README's "A look
around" table. All other apps have three shots, so their layout is
unchanged.

### Download versions
| App | Before | After |
|---|---|---|
| PhotoCraft | 0.2.0 | **0.3.0** |
| VectorCraft | 0.3.1 | **0.4.0** |
| EffectCraft | 0.3.1 | **0.4.0** |
| FilmCraft, LightCraft, PrintCraft, DesignCraft | 0.2.1 | 0.2.1 (still
latest) |

These three releases add **Windows on ARM** builds (`windows-arm64.msi`,
`windows-arm64-portable.zip`). They're now listed in the Windows card
for those apps through a `WITH_WINDOWS_ARM64` asset list. The main
Windows button stays on x64, because browsers on Windows on ARM
generally report themselves as x64.

## Verification
- Every expected asset exists in each release.
- Rendered all seven `/apps/<slug>` pages locally and requested every
GitHub download and release link. All of them resolve, and the "Version"
label shows the new versions.
- Checked the VectorCraft gallery in the browser: a 2×2 grid with all
images loading.
- `tsc` shows no errors in the changed files. The errors that remain are
already on `main` (missing local Playwright types and
`media-viewer-3d.tsx`).

## Not in this PR
- **`storytold/printcraft` has been renamed to `storytold/pdfcraft`.**
PrintCraft links still work through GitHub's redirect. Renaming the app
on the site is a separate change.
- Releases also ship FreeBSD tarballs, and PhotoCraft also ships Flatpak
and zsync files. The site doesn't list these.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:51:45 +07:00
dafe769cda PageScene: dispose GPU assets on webapp (#55)
Targets PageScene and other 3D viewers, handles uniforms and textures,
amongst other general editor mount improvements

---------

Co-authored-by: heojunfo <kleague3@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:58:27 -07:00
BFlatandClaude Opus 5.5 ad9b10ba94 Bump Crafting App downloads to their latest releases (#54)
## What

Updates the `/apps/<slug>` download links to each app's latest GitHub
release:

| App | Before | After |
|---|---|---|
| VectorCraft | 0.2.0 | **0.3.1** |
| EffectCraft | 0.2.0 | **0.3.1** |
| FilmCraft | 0.2.0 | **0.2.1** |
| LightCraft | 0.2.0 | **0.2.1** |
| PrintCraft | 0.2.0 | **0.2.1** |
| DesignCraft | 0.2.0 | **0.2.1** |
| PhotoCraft | 0.2.0 | 0.2.0 (still its latest) |

`CRAFTING_APPS_LATEST_VERSION` becomes `0.2.1`, the most common version.
PhotoCraft, VectorCraft and EffectCraft are pinned with
`craftRelease(...)`, following the file's documented convention.

## Verification

- Compared each release's asset list against `CRAFT_RELEASE_ASSETS`:
every expected file exists.
- Rendered all seven app pages locally and requested every GitHub
download/release link (34 per page): all return 200/302.
- The "Version" label and JSON-LD `softwareVersion` show the new
versions.

## Note

PrintCraft 0.2.1 also ships `printcraft-0.2.1-freebsd-x86_64.tar.gz`,
which the site doesn't list. That's left for a follow-up if we want to
offer it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:38:45 +07:00
BFlatandClaude Opus 5.5 3880127f68 Add Google Analytics to the marketing site (#50)
## What

Adds GA4 to `artcraft-website-next`, covering the whole marketing site.

- `src/components/analytics.tsx` loads gtag.js (`afterInteractive`) and
installs one delegated click listener. It's mounted once in the root
layout.
- `src/lib/analytics.ts` holds the typed event catalogue,
`trackEvent()`, `trackAttrs()` (data attributes that work in server
components) and the link classifier.
- Analytics is **off unless `NEXT_PUBLIC_GA_MEASUREMENT_ID` is set**, so
local dev and deploy previews stay out of the reports.

## Why

The new site had no analytics. This gives us download, signup and
checkout funnels, plus CTA performance by page section.

## Events

GA4 Enhanced Measurement handles page views (App Router history
changes), scrolls, outbound clicks and common file downloads. On top of
that:

| Event | Trigger |
|---|---|
| `app_download` | GitHub release asset clicks (ArtCraft + Crafting
Apps): app, version, platform, file |
| `webapp_click` | Links to app.getartcraft.com: destination path |
| `cta_click` | Other `Button` links (tagged `data-cta`) |
| `app_select` | App cards and icons on `/apps` and sibling cards on app
pages: app name |
| `social_click`, `contact_click` | `SOCIAL_LINKS` hosts, mailto / copy
email |
| `begin_checkout` | Plan selection (`signup`/`subscribe`/`switch`) and
credit packs, with GA4 ecommerce `items` |
| `billing_cadence_change`, `view_credit_packs`, `manage_subscription` |
Pricing actions |
| `prompt_submit`, `video_generation` | H3 promptbox (`logged_in`;
complete/failed/timeout) |
| `sign_up`, `login` | Auth gate success |
| `generate_lead` | Beta form submission (honeypot hits excluded) |
| `video_start`, `share`, `copy_command`, `copy_prompt`,
`media_download`, `press_kit_download` | Video facades, share bars, copy
buttons, media/press downloads |

Every delegated click event also carries `link_location`: nav, footer,
modal, or the enclosing `section[id]`.

## Reviewer notes

- `Button` now renders `data-cta=""` on its anchor variants. There is no
visual change.
- `PricingTable.choosePlan` now branches on the computed `checkoutType`
rather than repeating the `user`/`hasActivePlan` conditions. The
behaviour is unchanged.
- `AuthGateModal` forms now return early on error instead of using
if/else, so the success event goes on the success path.

## Deploy

Set `NEXT_PUBLIC_GA_MEASUREMENT_ID` in Netlify, **Production context
only**, then redeploy. The ID is inlined at build time. This is
documented in `netlify.toml`.

After deploying:
- Mark key events in GA: `app_download`, `begin_checkout`, `sign_up`,
`generate_lead`, `webapp_click`.
- Register custom dimensions (e.g. `link_location`, `app_name`,
`platform`, `destination`, `checkout_type`) so the params show in
reports.

## Testing

Ran the dev server with a dummy measurement ID and checked `dataLayer`
on these pages:
- `/`: nav, footer, CTA, social, mailto and YouTube events.
- `/download`: Windows and macOS installers.
- `/apps/photocraft`: Linux downloads, share, copy command, GitHub link,
and `app_select` on the sibling cards (also checked on the `/apps` icon
row and lineup cards).
- `/pricing`: cadence toggle.
- `/minimax-h3`: logged-out `prompt_submit` and example `video_start`.

Not exercised in the browser: the beta form (submitting would post a
real Google Form entry), signup/login, checkout, the credits modal, the
media page, the press kit, email copy and manage plan.

`tsc` shows no new errors. Existing errors in `media-viewer-3d.tsx` and
the Playwright test files are unchanged. The production build was not
run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:00:50 +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 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
617 changed files with 12477 additions and 29669 deletions

No files matched your search

+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Free up disk on the runner before BuildKit fills it up. The cargo
# release build inside the Dockerfile produces multi-GB target dirs
@@ -60,10 +60,10 @@ jobs:
# BuildKit is required for the `cache-from`/`cache-to` options on
# docker/build-push-action.
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
name: Set up Docker Buildx
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
name: Login to GitHub Container Registry
with:
registry: ghcr.io
@@ -80,7 +80,7 @@ jobs:
- id: meta
name: Compute Docker tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/storytold/artcraft-services
tags: |
@@ -89,7 +89,7 @@ jobs:
type=raw,value=${{ steps.vars.outputs.sha_short }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: ./build/service_cpu.Dockerfile
@@ -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"
}
+10 -9
View File
@@ -34,16 +34,16 @@ artcraft-services/
├── frontend/
│ ├── 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
```
## Updating Desktop Download Links
Desktop release links are configured independently in **three places**. When
Desktop release links are configured independently in **two places**. When
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
`MAC_VERSION` in all three unless the request explicitly targets one platform or
`MAC_VERSION` in both unless the request explicitly targets one platform or
app:
1. **Current public website (Next.js):**
@@ -51,11 +51,7 @@ app:
This controls the installer links and visible version at
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
(newest first), then update both version selectors.
2. **Legacy website (Vite):**
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
Updating only this file does **not** update the current public website.
3. **Browser webapp:**
2. **Browser webapp:**
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
Update both version constants; these links are used by the welcome and
checkout-success pages at `app.getartcraft.com`.
@@ -64,7 +60,7 @@ Keep previous releases for rollback. The Next.js download page reads its visible
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
not hardcode a separate version in the page component.
Check that all three configs produce the requested release tag and installer
Check that both configs produce the requested release tag and installer
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
@@ -88,6 +84,11 @@ report a local update as a deployed update.
Organize for top-to-bottom reading. Important things first, details later.
- **`mod.rs` holds module declarations only** (`pub mod foo;`, plus re-exports if needed). Never
put functions, structs, enums, constants, impls or tests in a `mod.rs`: give each its own file
in that directory (for example `threads/poll_foo_thread/poll_foo.rs`, declared from
`threads/poll_foo_thread/mod.rs`).
- **Constants** at the top (after imports)
- **Structs/enums** next; outer structs above inner sub-structs
- **API types** in order: Request, Response, Error
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" }
+5 -8
View File
@@ -104,7 +104,7 @@ and signals for state, Three.js for 3D scenes, and shared UI and generation tool
| Path | Purpose |
|----------------------------------|-----------------------------------------------|
| `frontend/apps/artcraft-webapp` | Browser application at `app.getartcraft.com` |
| `frontend/apps/artcraft-website` | Product website at `getartcraft.com` |
| `frontend/apps/artcraft-website-next` | Product website at `getartcraft.com` (Next.js) |
| `frontend/libs/api` | HTTP clients, API host selection, and models |
| `frontend/libs/omni-gen` | Shared generation logic |
| `frontend/libs/components` | Reusable UI, editors, and generation controls |
@@ -190,14 +190,11 @@ The browser app runs at `http://localhost:4201`. `VITE_USE_LOCAL_API=true` selec
the hosted API. API host selection lives in
[`StorytellerApiHostStore`](./frontend/libs/api/src/lib/config/StorytellerApiHostStore.ts).
From `frontend`, build the web app or run the product website with:
From `frontend`, build the web app with `npx nx build artcraft-webapp`. The
product website is a standalone Next.js app (not part of the Nx workspace); run it
from the repository root with `./script/website/unix_frontend_website_dev.sh`.
```bash
npx nx build artcraft-webapp
npx nx dev artcraft-website
```
The website runs at `http://localhost:4200`. Repository-root launchers are in
The website runs at `http://localhost:3000`. Repository-root launchers are in
[`script/website`](./script/website). For desktop development, use the
[ArtCraft desktop repository](https://github.com/storytold/artcraft).
-1
View File
@@ -255,7 +255,6 @@ From the services repository:
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
cd frontend
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
```
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
+2 -2
View File
@@ -35,6 +35,6 @@ VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
The web app runs at `http://localhost:4201`, using the local backend. To develop
against the hosted API, omit `VITE_USE_LOCAL_API=true`.
For the product website, run `npx nx dev artcraft-website` from `frontend`; it uses
`http://localhost:4200`. Repository-root launchers live under [`script/website`](../script/website).
For the product website, run `./script/website/unix_frontend_website_dev.sh` from the
repository root; it is a standalone Next.js app on `http://localhost:3000`. Repository-root launchers live under [`script/website`](../script/website).
See the [frontend README](../frontend/README.md) for builds and troubleshooting.
+59
View File
@@ -0,0 +1,59 @@
# Copy this file to `.env` and change the values.
# `.env` is ignored by git; never commit real keys.
# ----- Local infrastructure (used by docker-compose.yml) -----
MYSQL_ROOT_PASSWORD=change-me-root
MYSQL_PASSWORD=change-me
# Credentials for the local S3-compatible storage (SeaweedFS).
S3_ACCESS_KEY=artcraft
S3_SECRET_KEY=change-me-please
# ----- storyteller-web settings -----
# Cookies are served over plain HTTP locally.
COOKIE_SECURE=false
COOKIE_DOMAIN=localhost
COOKIE_SECRET=change-me
SORT_KEY_SECRET=change-me
WEBSITE_HOMEPAGE_REDIRECT=http://localhost:4201
ENABLE_PAGING=false
NUM_WORKERS=4
# ----- Third-party providers -----
# storyteller-web requires these to be set at startup. Placeholders let the server
# start; features backed by a provider only work once you add a real key.
FAL_API_KEY=unset
FAL_WEBHOOK_URL=http://localhost:12345/v1/webhooks/fal
GMICLOUD_API_KEY=unset
GROK_API_KEY=unset
OPENAI_API_KEY=unset
WORLDLABS_API_KEY=unset
BEEBLE_API_KEY=unset
BEEBLE_WEBHOOK_URL=http://localhost:12345/v1/webhooks/beeble
SEEDANCE2PRO_COOKIES=unset
SEEDANCE2PRO_WHITELIST_COOKIES=unset
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=unset
# Email (Resend)
RESEND_API_KEY=unset
# ----- Stripe (billing) -----
STRIPE_SECRET_KEY=unset
STRIPE_SECRET_WEBHOOK_SIGNING_KEY=unset
STRIPE_FAKEYOU_ACCOUNT_ID=unset
STRIPE_PORTAL_DEFAULT_CONFIG_ID=unset
STRIPE_CHECKOUT_SUCCESS_URL_PATH=/checkout_success
STRIPE_CHECKOUT_CANCEL_URL_PATH=/checkout_cancel
STRIPE_PORTAL_RETURN_URL_PATH=/portal_success
STRIPE_ARTCRAFT_ACCOUNT_ID=unset
STRIPE_ARTCRAFT_SECRET_KEY=unset
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=unset
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL=http://localhost:4201/checkout_success
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL=http://localhost:4201/checkout_cancel
STRIPE_ARTCRAFT_PORTAL_RETURN_URL=http://localhost:4201/portal_closed
+77
View File
@@ -0,0 +1,77 @@
# Self-hosting with Docker Compose
This directory runs the ArtCraft backend (`storyteller-web`) and the services it
depends on with Docker Compose.
| Service | Image | Purpose |
|-------------------|--------------------------|---------------------------------------------|
| `storyteller-web` | built from this repo | HTTP API, on `http://localhost:12345` |
| `mysql` | `mysql:8.4` | Accounts, media metadata, jobs, billing |
| `migrate` | `mysql:8.4` | Applies `_database/sql/migrations`, exits |
| `redis` | `redis:7` | Caching, rate limiting, job progress |
| `elasticsearch` | `elasticsearch:8.15.3` | Search |
| `seaweedfs` | `chrislusf/seaweedfs` | S3-compatible storage for media |
| `storage-init` | `chrislusf/seaweedfs` | Creates the storage buckets, exits |
Only the API port (`12345`) is published to the host.
## Requirements
- Docker with Docker Compose v2.23 or newer.
- About 8 GB of memory for Docker and 20 GB of free disk. The first build
compiles the Rust workspace in release mode and can take a long time.
## Usage
From this directory:
```bash
cp .env.example .env # then edit the passwords and secrets
docker compose up --build -d
```
Check that the server is up:
```bash
curl http://localhost:12345/_status
```
Stop everything with `docker compose down`. Add `-v` to also delete the
database, search index and stored media.
## Configuration
Settings live in `.env`, which git ignores. `docker-compose.yml` sets the
connection settings for MySQL, Redis, Elasticsearch and storage, so you only
need to change passwords and secrets.
`storyteller-web` requires credentials for every third-party provider (FAL,
OpenAI, Stripe, Resend and others) to be present at startup. `.env.example`
sets them to the placeholder `unset`, which lets the server start. Features
that call a provider only work after you add a real key for it.
Provider webhooks, such as `FAL_WEBHOOK_URL`, must be reachable from the
internet for asynchronous generation results to arrive.
## Database migrations
The `migrate` service applies every migration in `_database/sql/migrations` in
order and records each one in `__diesel_schema_migrations`, the table the diesel
CLI uses. It runs on every `docker compose up` and only applies migrations that
have not run yet.
## Connecting the web app
To point the browser app at this server, run it from `frontend` as described in
the repository README:
```bash
VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
```
## Limitations
- Only the HTTP API is included. Background workers (provider polling, video
thumbnails, email, analytics) are not started.
- Elasticsearch starts with no indexes. Index definitions are in
`_database/elasticsearch/index_definitions`.
+163
View File
@@ -0,0 +1,163 @@
# Self-hosted ArtCraft backend (storyteller-web) with its dependencies.
#
# Usage (from this directory):
# cp .env.example .env
# docker compose up --build
#
# See README.md in this directory for details.
name: artcraft-selfhost
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: storyteller
MYSQL_USER: storyteller
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
# NB: Use TCP (127.0.0.1), not the socket, so the check fails while the image's
# temporary init server (which has networking disabled) is still running.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}", "--silent"]
interval: 5s
timeout: 5s
retries: 30
# Applies the SQL migrations in `_database/sql/migrations` in order.
# Applied versions are recorded in `__diesel_schema_migrations` (the same table
# the diesel CLI uses), so re-running is safe and only applies new migrations.
migrate:
image: mysql:8.4
depends_on:
mysql:
condition: service_healthy
environment:
MYSQL_PWD: ${MYSQL_ROOT_PASSWORD}
volumes:
- ../../_database/sql/migrations:/migrations:ro
entrypoint: ["bash", "-c"]
command:
- |
set -euo pipefail
run_sql() { mysql -h mysql -u root storyteller "$$@"; }
run_sql -e "CREATE TABLE IF NOT EXISTS __diesel_schema_migrations (
version VARCHAR(50) PRIMARY KEY NOT NULL,
run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)"
for dir in /migrations/*/; do
name=$$(basename "$$dir")
version=$$(echo "$${name%%_*}" | tr -d '-')
if [ -n "$$(run_sql -N -e "SELECT 1 FROM __diesel_schema_migrations WHERE version = '$$version'")" ]; then
continue
fi
echo "Applying migration $$name"
run_sql < "$$dir/up.sql"
run_sql -e "INSERT INTO __diesel_schema_migrations (version) VALUES ('$$version')"
done
echo "Migrations complete."
redis:
image: redis:7
restart: unless-stopped
volumes:
- redis-data:/data
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
restart: unless-stopped
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: -Xms512m -Xmx512m
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fs http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 30
# S3-compatible object storage (SeaweedFS) for uploaded and generated media.
seaweedfs:
image: chrislusf/seaweedfs:3.97
restart: unless-stopped
command: server -dir=/data -s3 -s3.port=8333 -s3.config=/etc/seaweedfs/s3.json
configs:
- source: seaweedfs-s3
target: /etc/seaweedfs/s3.json
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8333/healthz || exit 1"]
interval: 5s
timeout: 5s
retries: 30
# Creates the buckets storyteller-web expects.
storage-init:
image: chrislusf/seaweedfs:3.97
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["sh", "-c"]
command:
- |
set -e
for bucket in artcraft-private artcraft-public artcraft-public-gc; do
echo "s3.bucket.create -name $$bucket" | weed shell -master=seaweedfs:9333
done
storyteller-web:
build:
context: ../..
dockerfile: build/service_cpu.Dockerfile
image: artcraft-services:local
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
storage-init:
condition: service_completed_successfully
redis:
condition: service_started
elasticsearch:
condition: service_healthy
env_file: .env
environment:
SERVER_ENVIRONMENT: development
BIND_ADDRESS: 0.0.0.0:12345
MYSQL_URL: mysql://storyteller:${MYSQL_PASSWORD}@mysql:3306/storyteller
REDIS_0_URL: redis://redis:6379/0
ELASTICSEARCH_URL: http://elasticsearch:9200
S3_COMPATIBLE_ENDPOINT_URL: http://seaweedfs:8333
ACCESS_KEY: ${S3_ACCESS_KEY}
SECRET_KEY: ${S3_SECRET_KEY}
REGION_NAME: us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME: artcraft-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME: artcraft-public
GC_ENABLED_PUBLIC_BUCKET_NAME: artcraft-public-gc
AUDIO_UPLOADS_BUCKET_ROOT: /user_uploaded_audio
ports:
- "12345:12345"
configs:
seaweedfs-s3:
content: |
{
"identities": [
{
"name": "artcraft",
"credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}
volumes:
mysql-data:
redis-data:
elasticsearch-data:
seaweedfs-data:
@@ -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,
@@ -0,0 +1 @@
pub mod release_info;
@@ -0,0 +1,203 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use url::Url;
use utoipa::ToSchema;
// ── GET /v1/craft_apps/release_info ──
//
// The latest release of every Craft App (PhotoCraft, VectorCraft, …, and ArtCraft Launcher
// itself), as storyteller-web last fetched it from GitHub, plus renames and notices for the
// ArtCraft Launcher. The launcher polls this instead of GitHub; GitHub is its fallback.
//
// Compatibility: clients must ignore fields they don't know, and treat every field as optional.
// New fields may be added; existing ones keep their meaning.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GetCraftAppsReleaseInfoResponse {
pub success: bool,
pub release_info: CraftAppsReleaseInfo,
/// Apps that changed their name (for example PdfCraft → DocuCraft).
pub app_renames: Vec<CraftAppRename>,
/// Apps whose GitHub repository moved (for example `storytold/photocraft` → `art/photocraft`).
pub repo_renames: Vec<CraftAppRepoRename>,
pub important_notices: CraftAppsImportantNotices,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsReleaseInfo {
/// Keyed by app key: the app's lowercase machine name, as used in its binaries, asset file
/// names and `ai.storyteller.<key>` bundle id (`photocraft`, `cadcraft`, `artcraft-launcher`).
/// Not the display name and not the repo name. Clients should ignore keys they don't know.
pub apps: BTreeMap<String, CraftAppReleaseInfo>,
/// When the least recently fetched app was fetched: every app's data is at least this fresh.
/// Absent until every app has been fetched at least once.
pub last_fetched: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppReleaseInfo {
/// Display name (`PhotoCraft`).
pub name: String,
/// GitHub repository, `organization/project` (`storytold/photocraft`).
pub repo: String,
/// The latest published, non-draft, non-prerelease release. Absent when the repository has no
/// release yet, or when it hasn't been fetched yet (see `app_last_fetched`).
pub latest_release: Option<CraftAppRelease>,
/// When this app's release data was last fetched successfully. Absent if it never was; then
/// `latest_release` says nothing and clients should ask GitHub themselves.
pub app_last_fetched: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppRelease {
/// Git tag (`v0.5.0`).
pub tag: String,
/// The tag without its leading `v` (`0.5.0`, `0.6.0-rc.1`).
pub version: String,
/// Release title (`PhotoCraft v0.5.0`).
pub name: String,
pub published_at: Option<DateTime<Utc>>,
/// The release's page on GitHub.
pub html_url: String,
pub prerelease: bool,
/// Release notes (Markdown), truncated to 64 KiB.
pub notes_markdown: String,
pub assets: Vec<CraftAppReleaseAsset>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppReleaseAsset {
/// File name (`photocraft-0.5.0-windows-x64.msi`).
pub name: String,
pub size_bytes: u64,
/// `https://github.com/<repo>/releases/download/<tag>/<name>`.
pub download_url: String,
/// Lowercase hex SHA-256, when GitHub published one for the file.
pub sha256: Option<String>,
/// What the file name says it is, when it follows the Craft App naming scheme
/// (`<app>-<version>-<os>-<arch>[-portable].<ext>`).
pub platform: Option<CraftAppAssetPlatform>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppAssetPlatform {
pub os: CraftAppOs,
/// Absent for files that aren't for one CPU (the web build, checksums).
pub arch: Option<CraftAppArch>,
pub package_type: CraftAppPackageType,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CraftAppOs {
Windows,
Macos,
Linux,
Freebsd,
/// The browser build.
Web,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CraftAppArch {
X64,
X86,
Arm64,
/// macOS universal binary (Apple silicon and Intel).
Universal,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CraftAppPackageType {
/// Windows Installer.
Msi,
/// Windows portable zip.
PortableZip,
/// macOS disk image.
Dmg,
/// macOS command-line tools zip.
CliZip,
AppImage,
/// AppImage delta-update file.
AppImageZsync,
Deb,
Rpm,
Flatpak,
/// Linux or FreeBSD tarball.
TarGz,
/// The web (WebAssembly) build.
WebZip,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppRename {
/// User-facing name before (`PdfCraft`).
pub old_name: String,
/// Lowercase name before (`pdfcraft`).
pub old_name_lower: String,
/// User-facing name after (`DocuCraft`).
pub new_name: String,
/// Lowercase name after (`docucraft`).
pub new_name_lower: String,
/// A sentence or two to show beside the rename.
pub description: String,
pub renamed_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppRepoRename {
/// `organization/project` before.
pub old_name: String,
/// `organization/project` after.
pub new_name: String,
pub renamed_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsImportantNotices {
/// News, shown inline; the user can dismiss each.
pub announcements: Vec<CraftAppsAnnouncement>,
/// Shown as a popup the user acknowledges.
pub alerts: Vec<CraftAppsAlert>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsAnnouncement {
pub title: String,
pub body: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsAlert {
pub title: String,
pub body: String,
/// A page the popup can offer to open.
pub maybe_web_link: Option<Url>,
pub created_at: Option<DateTime<Utc>>,
}
@@ -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>,
}
@@ -2,6 +2,7 @@ pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod craft_apps;
pub mod credits;
pub mod folders;
pub mod generate;
@@ -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>,
}
+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,
@@ -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")?,
@@ -25,6 +25,7 @@ const RESULT_FIELDS: &str = r#"
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
m.maybe_thumbnail_version,
m.maybe_prompt_token,
@@ -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
@@ -38,6 +38,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_prompt_token: Option<PromptToken>,
@@ -90,6 +91,7 @@ pub(super) 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_prompt_token: Option<PromptToken>,
@@ -146,6 +148,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_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")?,
@@ -180,6 +183,7 @@ impl From<MediaFileListItemInternal> for MediaFileListItem {
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),
@@ -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?`,
Loaded 100 of 617 files, more files were not shown because too many files have changed in this diff. Show more