Compare commits

...

17 Commits

Author SHA1 Message Date
bflatastic 244ba8cbcb artcraft-native: custom title bar and Claude-style layout
Replace the native window frame with a custom title bar: the ArtCraft
mark (home) and sidebar toggle at the left, credits and the account menu
at the right, then minimize/maximize/close on Windows and Linux (macOS
keeps its traffic lights). The empty space drags the window and a
double-click maximizes; the edges and corners resize it.

The sidebar now sits flush with the window edge on the darker chrome,
and the page lives in an inset, rounded content panel whose header
holds the breadcrumbs and feed toggles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:53:53 +07:00
BFlat b5614ad333
Add artcraft-native: pure Rust (egui) desktop app, starting with Create Image and Create Video (#2067)
## What

A new workspace member, `artcraft-native/`. It is the ArtCraft desktop
app in pure Rust: an egui UI with no Tauri, no webview and no
JavaScript. It covers the two main product pages, **Create Image** and
**Create Video**, and reuses the Tauri-free API crates
(`artcraft_client`, `artcraft_api_defs`, `tokens`, `enums`).

It also adds:
- a Home page and a Library page
- a lightbox
- sign-in and settings dialogs
- a sidebar and top bar in the style of the brutalist
**artcraft-webapp**

Tools that aren't ported yet show a "coming soon" page that links to the
web app.

```bash
cargo run -p artcraft_native --release
```

## Why

To get a native, webview-free desktop app. Generation goes through
**OmniGen** (`/v1/omni_gen/{models,cost,generate}`), the same API as the
webapp. Model capabilities come from the server, so the pickers stay
current without app updates.

## Prompt box

Recreated feature for feature from `PromptBoxImage` / `PromptBoxVideo`:
- **Editor:** auto-grow, focus mode, resize grip, length counter with
the model's limit, Enter-to-generate setting, and `@Image1` / `@Video1`
/ `@Audio1` mentions with highlighting and autocomplete.
- **References:** the fanned reference deck (hover-expand, reorder, add
menus with limits) and the first/last keyframe slots with swap. Files
can be uploaded, picked from the library, dragged and dropped, or pasted
as images.
- **Model selector:** families with a flyout, maker logos and capability
badges.
- **Pickers:** aspect ratio, resolution, quality, count, bitrate, output
format, duration slider, sound and input mode (Keyframe / Omni
Reference).
- **Generate:** a live credit cost estimate, validation (starting frame,
text-only models, limits, uploads in progress), and clear-all with
confirmation.

The feed shows running jobs (with progress and time left), failures
(dismissable), finished results and the paginated library, in a masonry
grid or a list. It supports Recreate, Make Video, Share and Download,
plus select mode for batch downloads.

## Reviewer notes

- **Sign-in.** On first launch the app reuses an existing desktop login
by reading the session cookie from the Tauri app's jar, and keeps its
own copy in `~/Artcraft/credentials/native_session.json`. Browser
(device-code) and password sign-in are also available.
- **Decoding.**
- Generate and cost requests are sent as JSON maps, like the Tauri app's
omni path.
- Jobs, library, batch and prompt responses use lenient structs
(`backend/wire.rs`), so new server enum values can't break decoding.
- **Renderer.** It uses eframe's **glow** renderer. `wgpu-hal` fails to
build against the `windows` crate versions this workspace pins for
Tauri.
- **Model names and logos.** Known families use curated names: "Flux 1
Dev" rather than the server's "FLUX.1 [dev]". When the server reports
`artcraft` as a model's maker, the logo comes from the id prefix instead
(Nano Banana → Google), matching the webapp.
- **Fonts and assets.** Inter, Archivo and Geist Mono are bundled under
the OFL and listed in `NOTICE`. The wordmark, icon and maker logos are
the existing in-repo assets.
- **Diagnostics.** `ARTCRAFT_SCREENSHOT=<png>` (optionally with
`ARTCRAFT_PAGE` and `ARTCRAFT_SCREENSHOT_EXIT=1`) saves a screenshot
once the window has settled.

## Testing

- `cargo test -p artcraft_native`: 29 tests pass. Unit tests cover the
catalog, feed store, wire decoding and backend helpers. Headless
`egui_kittest` tests drive the prompt box: generate gating,
Enter-to-generate, pickers, `@` mentions and clear-all.
- `cargo clippy -p artcraft_native --all-targets --no-deps` is clean.
- Run against a real account: the session import, model lists, cost
estimates, job polling, library paging and thumbnails all worked.
- **Not exercised: a real paid generation and real uploads.** I held
back to avoid spending credits, so they need a manual check.

## Not done yet

- `@Character` mentions for Seedance 2.0
- Provider choice (Midjourney direct, fal); everything runs through
ArtCraft
- In-app video and audio playback; these open in the system player
- Animated previews in the feed
- The remaining tools (Audio, 3D, editors)

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:44:59 +07:00
Brandon Thomas 8abde75133
Relicense ArtCraft under MIT OR Apache-2.0 (#2043)
## Summary
- Removes the WIP fair-source `LICENSE.md` and adds `LICENSE-MIT` and
`LICENSE-APACHE`, matching PhotoCraft, LightCraft, and the other craft
apps.
- Adds `NOTICE`, which lists bundled third-party material (ammo.js, the
vendored tauri-plugin-http, normalize.css, three-dots, Font Awesome
icons, the Carter One font) and says that provider logos are trademarks
of their owners.
- Updates the README's License section, and sets `license` in
`crates/desktop/artcraft/Cargo.toml` and `frontend/package.json` to `MIT
OR Apache-2.0`.

## Follow-up needed
`NOTICE` excludes assets that ArtCraft doesn't own: Nintendo sound
effects in `resources/sound/`, meme images, the AllSky-style gray/night
skyboxes, the Lumine VMD, and the Mixamo animation GIFs. The exclusion
only says these files aren't MIT/Apache. They still need to be replaced
or removed in a separate PR. Nobody has confirmed where the avatars,
default covers, `characters/img*.png`, and the day skybox came from.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:14:38 -04:00
Brandon Thomas 910bfe4e84
artcraft 0.42.0 (#2026) 2026-10-09 13:03:51 -04:00
Brandon Thomas 7aebfc1c59
fix error messages so they're user friendly (#2025) 2026-10-09 13:01:10 -04:00
Tanish Manku 3e5793b693
PageScene: dispose GPU assets on desktop (#1972)
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:56:14 -07:00
BFlat 3210977fb1
Use the new ArtCraft icon for the app icon and favicons (#1965)
- Regenerate the desktop app icons (Windows, macOS, Store, Android, iOS)
from the new angular ArtCraft mark in the website logo blue
- Replace favicon.ico, the 16/32 favicons, and the apple-touch icon
- Add the SVG icon as the primary favicon, same as the webapp
2026-09-28 13:16:34 +07:00
BFlat 90c7a8a118
Match desktop app styling to the webapp (#1963) 2026-09-28 12:29:54 +07:00
Brandon Thomas 1b27c31531
thumbnail speedup (and fix for non-rendering thumbnails) (#1964) 2026-09-28 01:28:45 -04:00
Brandon Thomas 93b664d871
client identity (origin, user agent) (#1962) 2026-09-27 23:41:15 -04:00
Brandon Thomas 92e6f438c3
better dev script (#1961) 2026-09-27 14:48:34 -04:00
Brandon Thomas df707bf8e1
performance changes (#1959) 2026-09-26 17:50:36 -07:00
Brandon Thomas 780e4494e9
fix login video on desktop (#1958) 2026-09-26 17:08:31 -07:00
Brandon Thomas c3844acdad
ArtCraft 0.41 (#1957)
- website-based login
2026-09-26 03:43:28 -07:00
Brandon Thomas 8456e2fd06
website based login challenges (#1956) 2026-09-26 03:42:09 -07:00
Brandon Thomas 95f8f659a5
ArtCraft 0.40.0 (#1955) 2026-09-18 16:26:35 -04:00
Brandon Thomas 3c5038c159
Added new models and modes: GPT Image 2.5, Wan, Output Formats (#1954) 2026-09-18 16:23:44 -04:00
466 changed files with 23816 additions and 3480 deletions

View File

@ -44,6 +44,7 @@ explicitly authorizes a cache cleanup.
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
- Each Tauri command belongs in its own file module, named after the command. Keep shared state, types, and helpers in separate support modules; see the [Storyteller command conventions](crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md).
### File Layout

2913
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ resolver = "2"
# Desktop application and its local build/test dependencies.
members = [
"artcraft-native", # Pure Rust (egui) desktop app; no Tauri or webview
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
@ -16,6 +17,7 @@ members = [
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
"crates/desktop/artcraft",
"crates/lib/artcraft_client_identity",
"crates/lib/browser_emulation",
"crates/lib/build_metadata",
"crates/lib/caching/memory_store",
@ -49,6 +51,7 @@ publish = false
[workspace.dependencies]
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_client_identity = { path = "crates/lib/artcraft_client_identity" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
browser_emulation = { path = "crates/lib/browser_emulation" }
cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" }

177
LICENSE-APACHE Normal file
View File

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

21
LICENSE-MIT Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the ArtCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,86 +0,0 @@
ArtCraft License (WIP)
======================
(**Warning**: This isn't a real lawyery license full of legitimate legalese yet, but we'll take care of that ASAP.)
ArtCraft is a form of "fair source" software. It's like [free beer](https://en.wiktionary.org/wiki/free_as_in_beer)
in that we give you the code, but there are a few small limitations on what you can do with it.
First, what you *can* do with ArtCraft:
> 1. You do not have to pay us to use ArtCraft. It's free.
>
> 2. You can back up, copy, modify, compile, and use the code for your own private personal purposes.
>
> 3. You can do anything you want with the media you create with ArtCraft. Generated assets are yours.
What you *can't do* with ArtCraft:
> 1. You cannot sell the ArtCraft software commercially (eg. as your own software).
>
> 2. You cannot use the ArtCraft code to develop a competing business or product (eg. selling AI image and video tools).
>
> 3. You cannot fork ArtCraft and remove ArtCraft community, donation links, or paid model services. We intend to
> make money to improve ArtCraft for everyone.
>
> 4. You cannot use the ArtCraft name, logo, or mascot characters to promote your business without permission.
This license means you can see what we're doing and not be at risk of having the rug pulled out from under
you (eg. if our founders die, if our business collapses, if zombies or space aliens invade). It also gives
you a really great way to customize the software or lend a hand in reporting (or even fixing) bugs.
Our desire is to support artists, designers, and filmmakers. We want to help you build your own creative studios
and businesses. We want to give you creative software you can use forever.
ArtCraft is free. We do offer models available through us where you can pay us and support our development, but we also
offer the ability for you to use other providers - and we'll keep adding more options so you have maximum flexibility.
## Our Promise
- All development and business plans will be communicated openly with the community.
- Except for minor test scripts or a few small places where we might need to use licensed 3rd party code, all of
our engineering efforts will live in a single publicly available monorepo. You can see everything we do and even
benefit from using it for personal use. We plan on building a lot more than what you see today, and you'll get
all of it.
- We will not "rug pull" our users. If our business shuts down, gets acquired, or otherwise changes, the tools
we create will always continue to function in the same way.
- If the business ends and our founders quit working in this space, we'll make it so the software transitions
to an OSI-approved open source license. (We don't intend to quit!)
## Why not OSI Open Source?
Philosophical answer: The OSI is an industry body led by hyperscalers. They poach from open source all the time, yet
hyperscaler systems and code (AWS, GCP, YouTube, etc.) remain closed source. The tiniest nuggets are open, but the vast
machinery surrounding it - systems, user behaviors, funnels, at-scale moneymakers - is totally closed.
Practical answer: OSI licenses make it exceptionally challenging to grow big things commercially. And many of
the licenses leave room for hyperscalers to reach in and cherry-pick pieces for themselves that they can better
commercialize than the original authors.
Then there's completely open source software that doesn't even attempt to be a business.
We love software like Blender, but (1) developing ArtCraft is our full-time job, (2) we want to build something at scale
that is much bigger than what you presently see, and (3) this will eventually become much more capital intensive than
just writing code - we want to train novel models that work directly in conjunction with the user interface.
If and when we do get to scale, we intend to keep every bit of our work in the open, including the models. Available
for use, inspection, learning, fitting onto consumer GPU hardware, and more.
Not having a way to make money is unfortunately what killed Stability.ai. Stability.ai walked so that others could
eventually run. (Black Forest Labs, et al.) We're not egotistical enough to count ourselves in that party yet, but we
believe in the original Stability.ai ethos and that their model can work with a few tweaks.
We want to build big and incredible tools the world hasn't seen before. We want to keep them free and entirely source
available. You don't have to pay us, but if you do, all of your investment goes right back into the engineering
and the tools. We're writing code you can clone and keep.
## Inspiration
We took inspiration for this from [Fair Source](https://fair.io/), [Fair Code](https://faircode.io/),
the [Defold License](https://defold.com/license/), and Hacker News discussions. The folks at Obsidian
have some pretty good thoughts on this as well.
~ Brandon Thomas

51
NOTICE Normal file
View File

@ -0,0 +1,51 @@
ArtCraft
Copyright (c) 2026 ArtCraft Team and the ArtCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not covered
by the license above.
Third-party material
This product bundles third-party material under its own licenses, listed below.
ammo.js (frontend/apps/artcraft/app/public/jsm/libs/ammo.wasm.*) — zlib License. A port of the
Bullet Physics engine. https://github.com/kripken/ammo.js
tauri-plugin-http (crates/vendor/tauri-plugin-http/) — MIT or Apache-2.0. Copyright (c) 2017 -
Present Tauri Apps Contributors; Copyright 2019-2023 Tauri Programme within The Commons
Conservancy. Modified fork; see that directory's LICENSE_MIT and LICENSE_APACHE-2.0.
normalize.css v8.0.1 (frontend/apps/artcraft/app/src/styles/normalize.css) — MIT License.
Copyright (c) Nicolas Gallagher and Jonathan Neal. https://github.com/necolas/normalize.css
three-dots v0.3.2 (portion of frontend/apps/artcraft/app/src/styles/base.css) — MIT License.
Copyright (c) 2018 nzbin. https://nzbin.github.io/three-dots/
Font Awesome Free icons (frontend/libs/icons/src/lib/brand-icons.tsx,
frontend/apps/artcraft/app/public/resources/images/services/generic.svg) — CC BY 4.0.
Copyright Fonticons, Inc. https://fontawesome.com/license/free
Carter One font (frontend/apps/artcraft/app/public/resources/fonts/CarterOneRegular.json) —
SIL Open Font License 1.1. https://fonts.google.com/specimen/Carter+One
Inter, Archivo and Geist Mono fonts (artcraft-native/assets/fonts/) — SIL Open Font License 1.1;
each font's license is next to it. https://rsms.me/inter/ ·
https://fonts.google.com/specimen/Archivo · https://vercel.com/font
Third-party trademarks
The AI provider and service logos in frontend/apps/artcraft/app/public/resources/images/services/
are trademarks of their respective owners. They are used only to identify those services and are
not covered by the license above.
Material not covered by the license above
The following files are owned by third parties. They are not licensed under MIT or Apache-2.0,
and no rights to them are granted by this repository. They remain the property of their
respective owners. These assets will be phased out of ArtCraft over time.
frontend/apps/artcraft/app/public/resources/sound/ — sound effects from Nintendo games (Golden
Sun, Metroid Prime, The Legend of Zelda: Ocarina of Time, Super Mario RPG).
frontend/apps/artcraft/app/public/resources/memes/ — meme images and likenesses owned by their
respective rights holders.
frontend/apps/artcraft/app/public/resources/skybox/gray/ and skybox/night/ — skybox textures
from a third-party asset pack.
frontend/apps/artcraft/app/public/resources/pose/Lumine Idle cycle.vmd — third-party MMD motion
data.
frontend/apps/artcraft/app/public/resources/characters_gif/ — renders of Mixamo animations.

View File

@ -136,4 +136,10 @@ Additional provider integrations are planned for Kling, Google, Runway, and Luma
- [Developer documentation](./_docs)
- [Desktop build and development scripts](./script/artcraft)
- [Roadmap](./ROADMAP.md)
- [License](./LICENSE.md)
- [License](#license)
## License
ArtCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Copyright (c) 2026 ArtCraft Team and the ArtCraft contributors. Required notices, including
third-party material not covered by this license, are in [NOTICE](NOTICE).

View File

@ -9,21 +9,67 @@ To set up the ArtCraft development environment, install the following:
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
3. The frontend's Nx and Vite dependencies are installed locally with npm; a global Nx installation is not required for the combined Unix launcher.
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
```bash
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Start the frontend and Rust/Tauri together in one terminal
./script/artcraft/unix_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
# Optionally start the free-port search at another port
ARTCRAFT_DEV_PORT=6200 ./script/artcraft/unix_dev.sh
```
Use the combined launcher instead of starting the two legacy dev scripts. It
works from any working directory when invoked by its path and installs frontend
dependencies if the local Vite installation is missing. Node.js 20+ is required.
The launcher binds Vite to `127.0.0.1`, starting at port **5193** and trying
successive ports until one is available. It leaves existing listeners alone.
Only after the real frontend socket is bound does it start Tauri with that exact
`devUrl`. HTTP and the HMR websocket share the selected port; Rust communicates
through native Tauri IPC and needs no separate HTTP port. Configuration overrides
are passed in memory, without changing the checked-in Tauri config.
The native HTTP bridge normalizes loopback development origins for first-party
API requests so an automatically selected port does not break login or media
loading. See the [desktop session regression checks](../docs/desktop-session-regression.md)
when changing ports, the launcher, or authentication.
JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible
React component edits use Fast Refresh. Changes that cannot be hot-replaced
reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app
restart**, including dependent workspace crates. SQLx metadata and SQLite
migrations are also watched. Rust reload is a process restart, so it does not
preserve unsaved in-memory app state. See [Tauri's watch behavior](https://v2.tauri.app/develop/#reacting-to-source-code-changes).
After startup, a Vite configuration restart must reuse the chosen port or report
an error; it cannot silently switch to another port while Tauri uses an old URL.
Ctrl-C stops this session's frontend, Rust watcher/compiler, and desktop app.
If the Rust watcher exits, the launcher closes Vite and propagates the exit code.
It never finds or terminates another app by name or port.
The launcher retains the existing Unix defaults for `VITE_ENVIRONMENT_TYPE`,
`SQLX_OFFLINE`, `RUSTFLAGS`, and the Linux WebKit workarounds. Explicit environment
overrides are preserved. It uses the normal Artcraft app data and Cargo target
directory; port separation does not create an isolated account/data profile, and
concurrent Rust builds can wait on Cargo's build lock.
To verify the launcher without compiling Rust or opening a desktop window:
```bash
node --test frontend/scripts/unix-dev.test.mjs
```
The tests use real Vite listeners and HMR, with a test-owned fake Cargo process
tree. They cover occupied ports, simultaneous launches, React edits, shutdown,
Rust-process failure, and watcher arguments. To verify native Rust reload, run
the launcher, wait for the desktop window, make a Rust source edit, and check for
a rebuild/restart in the same terminal. Frontend edits should update the webview
without restarting the Rust process.
**Windows Development**
```powershell

View File

@ -0,0 +1,43 @@
[package]
name = "artcraft_native"
description = "ArtCraft desktop app in pure Rust: an egui UI over the ArtCraft API client (no Tauri, no webview)"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "artcraft-native"
path = "src/main.rs"
[dependencies]
# Internal (Tauri-free) API crates shared with the Tauri app.
artcraft_api_defs.workspace = true
artcraft_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
cookie.workspace = true
chrono.workspace = true
directories = "6.0.0"
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
egui = "0.36"
enums.workspace = true
egui_extras = { version = "0.36", default-features = false, features = ["image", "svg"] }
env_logger.workspace = true
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
log.workspace = true
open = "5"
reqwest = { workspace = true, features = ["multipart"] }
resvg = "0.45"
rfd = "0.16"
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time", "fs"] }
uuid = { workspace = true, features = ["v4"] }
# Pasting images from the clipboard (egui's own clipboard support is text only).
arboard = { version = "3", default-features = false, features = ["image-data"] }
[dev-dependencies]
egui_kittest = "0.36"

106
artcraft-native/README.md Normal file
View File

@ -0,0 +1,106 @@
# ArtCraft Native
ArtCraft's desktop app in pure Rust: an [egui](https://github.com/emilk/egui) UI over the same
ArtCraft API client crates the Tauri app uses. There is no Tauri, no webview and no JavaScript.
The look follows the brutalist webapp (`artcraft-services/frontend/apps/artcraft-webapp`). That
means near-black surfaces with white/15 hairlines and 3 px corners on controls. Type is Archivo for
display, Inter for body text and Geist Mono for labels. The brand blue marks the primary actions.
Create Image and Create Video are fully built. The other tools show a "coming soon" page that
links to the same tool in the web app.
## Running
```bash
cargo run -p artcraft_native --release
```
The workspace's default member is the Tauri app, so pass `-p artcraft_native`.
- **Sign-in.** On first launch the app reuses an existing ArtCraft login. It reads the session
cookie from the Tauri app's jar (`<cache dir>/ai.artcraft.app/.cookies`) and keeps its own copy in
`~/Artcraft/credentials/native_session.json`. Otherwise use "Sign in with browser" (device code)
or a username and password.
- **API host.** `~/Artcraft/settings/env_configs.json` selects it, exactly as for the Tauri app.
## Architecture
| Module | What it does |
|--------------------|--------------------------------------------------------------------------------|
| `backend` | Tokio runtime and every API call; results come back to the UI as `Event`s |
| `backend::wire` | Lenient response shapes; new server enum values can't break decoding |
| `backend::session` | Credentials: own file, Tauri cookie import, env config |
| `backend::media_cache` | Thumbnails and previews: downloaded and decoded off-thread, evicted when unused |
| `models` | Model catalog from OmniGen listings, plus names, families and limits |
| `prompt_box` | The shared prompt box: editor, reference deck, keyframes, pickers, model selector |
| `pages` | Create Image, Create Video, Home, Library, and the shared page shell |
| `feed` | Generation feed: running, failed and finished jobs, library paging, grid and list |
| `overlays` | Lightbox, library picker, sign-in and settings dialogs |
| `shell` | Custom title bar, sidebar flush to the window edge, content panel header |
| `theme`, `ui` | Tokens, Lucide-style icons, widgets, toasts, window chrome (drag, resize, caption buttons) |
How the backend works:
- **Generation.** It goes through OmniGen: `/v1/omni_gen/{models,cost,generate}/{image,video}`.
Model capabilities come from the server, so new models and options appear without an app update.
- **Typed client functions.** Model lists, uploads, credits, login and deletes use
`artcraft_client`.
- **Lenient decoding.** Generate and cost requests are JSON maps, as in the Tauri app. Job polling
(`/v1/jobs/session`), the library list, batches and prompts are decoded with lenient structs.
- **Identity headers.** Every request sends the desktop identity headers: `Origin` and
`User-Agent`.
## Prompt box parity
| Feature | Status |
|-----------------------------------------------------------------|-----------------------------------------|
| Auto-growing prompt (~4 lines), expand/collapse, resize grip | Done |
| Focus mode (fullscreen editor with deck and toolbar) | Done |
| Length counter, red over the model's limit | Done |
| Enter to generate (setting; Shift+Enter for a new line) | Done |
| `@Image1` / `@Video1` / `@Audio1` highlight and autocomplete | Done |
| Reference deck: fan, +N badge, hover-expand, reorder, clear all | Done |
| Keyframe slots: first/last frame, tilt, swap | Done |
| Upload, pick from library, drag & drop, paste image | Done (video uploads must be MP4) |
| Model selector with families, logos, capability badges | Done |
| Aspect ratio, resolution, quality, count | Done |
| Bitrate, output format, duration slider, sound, input mode | Done |
| Live credit cost on the generate button | Done |
| Validation: starting frame, text-only banner, limits, uploads | Done |
| Clear all (confirms when references are attached) | Done |
| `@Character` mentions and the Characters modal (Seedance 2.0) | Not yet |
| Provider choice (Midjourney direct, fal) | Not yet; everything runs via ArtCraft |
| In-app video and audio playback | Opens in the system player instead |
| Animated video previews in the feed | Still frames only |
Feed and lightbox:
- **Feed.** Pending cards show progress, time left and a batch banner. Failed cards show the reason
and can be dismissed. The grid is masonry, with a list view alongside. Hover actions are Recreate,
Make Video, Share and Download. Select mode does batch downloads. The last viewed item is marked.
- **Lightbox.** Prev/next with the arrow keys, prompt copy and details. Actions are Recreate, Make
Video, Download, Share and Delete.
## Diagnostics
Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has settled.
`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video` or `library`) picks the page, and
`ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards.
## Tests
```bash
cargo test -p artcraft_native
```
There are unit tests for the catalog, feed store, wire decoding and backend helpers. Headless UI
tests (`egui_kittest`) drive the prompt box: generate gating, Enter to generate, the pickers,
`@` mentions and clear-all.
## Fonts and brand assets
`assets/fonts` holds Inter, Archivo and Geist Mono, all under the SIL Open Font License; the
licences sit next to the files. The wordmark, app icon and creator logos are the Tauri app's own
(`frontend/apps/artcraft/app/public/resources`, `crates/desktop/artcraft/icons`); see `NOTICE`
for their terms.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,93 @@
Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

738
artcraft-native/src/app.rs Normal file
View File

@ -0,0 +1,738 @@
//! The app: owns the backend, the pages and the overlays; routes backend events to whoever is
//! waiting for them and page requests to the services that fulfil them.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use crate::backend::media_cache::MediaCache;
use crate::backend::wire::{Prompt, SessionUser};
use crate::backend::{Backend, Event, Modality, SHARE_URL_BASE};
use crate::feed::grid::{RatioCache, ViewMode};
use crate::feed::store::{FeedNotice, FeedStore};
use crate::feed::types::{FeedItem, MediaKind};
use crate::models::Catalog;
use crate::overlays::account::{LoginAction, LoginDialog, SettingsAction, settings_dialog};
use crate::overlays::library_picker::{LibraryPicker, PickerAction};
use crate::overlays::lightbox::{Lightbox, LightboxAction, fit_inside};
use crate::pages::common::{self, AppRequest, Env, FeedKey};
use crate::pages::create_image::{CreateImagePage, ImageSettings};
use crate::pages::create_video::{CreateVideoPage, VideoSettings};
use crate::pages::other;
use crate::prompt_box::refs::{RefKind, RefMedia};
use crate::shell::{self, Account, Page, ShellAction};
use crate::theme;
use crate::ui::toast::Toasts;
use crate::ui::{creator_icons, widgets, window};
/// Builds the page environment from the app's fields (disjoint borrows, so pages can be
/// borrowed mutably alongside it).
macro_rules! env {
($app:ident, $ctx:expr) => {
Env { ctx: $ctx.clone(), backend: &$app.backend, cache: &mut $app.cache, toasts: &mut $app.toasts, catalog: &$app.catalog, signed_in: $app.user.is_some(), enter_to_generate: $app.enter_to_generate, view_mode: $app.view_mode, ratios: &mut $app.ratios, prompts: &$app.prompts, requests: &mut $app.requests }
};
}
const STORAGE_KEY: &str = "artcraft-native";
const JOB_POLL_INTERVAL: f64 = 5.0;
const CREDITS_INTERVAL: f64 = 60.0;
const MODEL_RETRY_INTERVAL: f64 = 15.0;
/// List view: prompt records fetched per frame at most.
const PROMPT_FETCHES_PER_FRAME: usize = 8;
/// Preferences that survive restarts.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
struct Saved {
page: Page,
enter_to_generate: bool,
view_mode: ViewMode,
sidebar_open: bool,
image: ImageSettings,
video: VideoSettings,
}
impl Default for Saved {
fn default() -> Self {
Self { page: Page::CreateImage, enter_to_generate: false, view_mode: ViewMode::Grid, sidebar_open: true, image: ImageSettings { count: 1, ..Default::default() }, video: VideoSettings::default() }
}
}
pub struct ArtcraftApp {
backend: Backend,
cache: MediaCache,
toasts: Toasts,
catalog: Catalog,
user: Option<SessionUser>,
credits: Option<u64>,
page: Page,
enter_to_generate: bool,
view_mode: ViewMode,
sidebar_open: bool,
image: CreateImagePage,
video: CreateVideoPage,
library: FeedStore,
ratios: RatioCache,
/// Prompt texts by prompt token (list view, lightbox).
prompts: HashMap<String, String>,
prompt_records: HashMap<String, Prompt>,
requested_prompts: HashSet<String>,
/// A Recreate waiting for its prompt record.
pending_recreate: Option<(MediaKind, String)>,
/// Which feed a batch expansion belongs to.
batch_kinds: HashMap<String, MediaKind>,
lightbox: Option<Lightbox>,
picker: Option<LibraryPicker>,
login: Option<LoginDialog>,
settings_open: bool,
preview: Option<String>,
requests: Vec<AppRequest>,
screenshot: Option<ScreenshotRequest>,
next_poll: f64,
next_credits: f64,
models_retry_at: Option<f64>,
}
impl ArtcraftApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
theme::apply(&cc.egui_ctx);
egui_extras::install_image_loaders(&cc.egui_ctx);
let mut saved: Saved = cc.storage.and_then(|s| eframe::get_value(s, STORAGE_KEY)).unwrap_or_default();
if let Some(page) = std::env::var("ARTCRAFT_PAGE").ok().and_then(|p| Page::from_slug(&p)) {
saved.page = page;
}
let backend = Backend::new(cc.egui_ctx.clone());
let cache = MediaCache::new(backend.runtime(), backend.http());
backend.load_models(Modality::Image);
backend.load_models(Modality::Video);
backend.refresh_session();
Self { cache, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }
}
// --- Events from the backend -------------------------------------------------------------
fn handle_event(&mut self, ctx: &egui::Context, event: Event) {
match event {
Event::Session(user) => {
let changed = self.user.as_ref().map(|u| &u.username) != user.as_ref().map(|u| &u.username);
self.user = user;
if changed {
self.image.feed.reset();
self.video.feed.reset();
self.library.reset();
self.next_poll = 0.0;
}
if self.user.is_some() {
self.login = None;
} else {
self.credits = None;
if let Some(login) = &mut self.login {
login.busy = false;
}
}
},
Event::LoginFailed(message) => {
if let Some(login) = &mut self.login {
login.busy = false;
login.device = None;
login.error = Some(message);
}
},
Event::DeviceLogin { url, code } => {
if let Some(login) = &mut self.login {
login.device = Some((url, code));
}
},
Event::Credits(credits) => self.credits = Some(credits),
Event::Models(modality, Ok(models)) => match modality {
Modality::Image => self.catalog.set_image(models),
Modality::Video => self.catalog.set_video(models),
},
Event::Models(_, Err(message)) => {
if self.models_retry_at.is_none() {
self.toasts.error(format!("Couldn't load models: {message}"));
}
self.models_retry_at = Some(ctx.input(|i| i.time) + MODEL_RETRY_INTERVAL);
},
Event::Cost { key, credits } => {
self.image.cost.apply(&key, credits);
self.video.cost.apply(&key, credits);
},
Event::Enqueued { modality, job_tokens, meta } => {
let mut env = env!(self, ctx);
match modality {
Modality::Image => self.image.on_enqueued(&mut env, &job_tokens, &meta),
Modality::Video => self.video.on_enqueued(&mut env, &job_tokens, &meta),
}
self.next_poll = ctx.input(|i| i.time) + 2.0;
},
Event::EnqueueFailed { modality, message } => {
let mut env = env!(self, ctx);
match modality {
Modality::Image => self.image.on_enqueue_failed(&mut env, &message),
Modality::Video => self.video.on_enqueue_failed(&mut env, &message),
}
},
Event::Uploaded { ref_id, result } => {
if self.image.refs.find_mut(ref_id).is_some() {
common::apply_upload(&mut self.toasts, &mut self.image.refs, ref_id, result);
} else if common::apply_upload(&mut self.toasts, &mut self.video.refs, ref_id, result) {
let mut env = env!(self, ctx);
self.video.check_durations(&mut env, ref_id);
}
},
Event::Jobs(jobs) => {
for (kind, notices) in [(MediaKind::Image, self.image.feed.apply_jobs(MediaKind::Image, &jobs, &self.catalog)), (MediaKind::Video, self.video.feed.apply_jobs(MediaKind::Video, &jobs, &self.catalog))] {
for notice in notices {
match notice {
FeedNotice::Completed => {
self.toasts.success(format!("{} generation complete!", kind.label()));
self.backend.refresh_credits();
},
FeedNotice::Failed(reason) => self.toasts.error(reason),
FeedNotice::LoadBatch { job_token, batch_token } => {
self.batch_kinds.insert(job_token.clone(), kind);
self.backend.load_batch(job_token, batch_token);
},
}
}
}
},
Event::LibraryPage { key, page, items, total_pages } => match key.as_str() {
"picker" => {
if let Some(p) = &mut self.picker {
p.apply_page(page, items, total_pages);
}
},
"image" => self.image.feed.apply_library_page(MediaKind::Image, page, &items, total_pages),
"video" => self.video.feed.apply_library_page(MediaKind::Video, page, &items, total_pages),
_ => self.library.apply_library_page(MediaKind::Image, page, &items, total_pages),
},
Event::LibraryFailed { key, message } => {
match key.as_str() {
"picker" => {
if let Some(p) = &mut self.picker {
p.loading = false;
p.has_more = false;
}
},
"image" => self.image.feed.library_failed(),
"video" => self.video.feed.library_failed(),
_ => self.library.library_failed(),
}
self.toasts.error(format!("Couldn't load your library: {message}"));
},
Event::Batch { job_token, items } => match self.batch_kinds.remove(&job_token) {
Some(MediaKind::Video) => self.video.feed.apply_batch(MediaKind::Video, &items),
_ => self.image.feed.apply_batch(MediaKind::Image, &items),
},
Event::Prompt(prompt) => {
if let Some(text) = &prompt.maybe_positive_prompt {
self.prompts.insert(prompt.token.clone(), text.clone());
}
if self.pending_recreate.as_ref().is_some_and(|(_, t)| *t == prompt.token) {
if let Some((kind, _)) = self.pending_recreate.take() {
self.apply_recreate(kind, &prompt);
}
}
self.prompt_records.insert(prompt.token.clone(), prompt);
},
Event::Deleted(token) => {
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
feed.remove_item(&token);
}
if self.lightbox.as_ref().is_some_and(|l| l.token == token) {
self.lightbox = None;
}
},
Event::Saved(path) => self.toasts.success(format!("Downloaded {}", common::file_name(&path))),
Event::Toast { error, message } => {
if error {
self.toasts.error(message);
} else {
self.toasts.success(message);
}
},
}
}
// --- Requests from the pages -------------------------------------------------------------
fn handle_request(&mut self, ctx: &egui::Context, request: AppRequest) {
match request {
AppRequest::SignIn => self.open_login(),
AppRequest::Open { kind, token } => {
if let Some(item) = self.find_item(&token) {
self.request_prompt(item.prompt_token.clone());
}
self.lightbox = Some(Lightbox::new(kind, token));
},
AppRequest::PickFromLibrary { kind, slot, max, page } => {
let Some(user) = &self.user else {
self.open_login();
return;
};
let refs = if page == MediaKind::Image { &self.image.refs } else { &self.video.refs };
let attached: HashSet<String> = refs.list(kind).iter().chain(refs.first_frame.iter()).chain(refs.last_frame.iter()).filter_map(|r| r.token.clone()).collect();
let mut picker = LibraryPicker::new(kind, slot, page, max, attached);
picker.loading = true;
self.backend.load_library("picker".to_owned(), user.username.clone(), picker.media_class(), 0);
self.picker = Some(picker);
},
AppRequest::MakeVideo { token } => {
let Some(item) = self.find_item(&token) else {
return;
};
let image = RefMedia::from_library(RefKind::Image, item.token.clone(), item.thumbnail.clone(), Some(item.full_url.clone()), 0.0);
self.video.set_start_image(image);
self.lightbox = None;
self.page = Page::CreateVideo;
},
AppRequest::Recreate { kind, token } => {
let Some(prompt_token) = self.find_item(&token).and_then(|i| i.prompt_token.clone()) else {
self.toasts.error("Could not load the original prompt.");
return;
};
match self.prompt_records.get(&prompt_token).cloned() {
Some(prompt) => self.apply_recreate(kind, &prompt),
None => {
self.pending_recreate = Some((kind, prompt_token.clone()));
self.requested_prompts.insert(prompt_token.clone());
self.backend.load_prompt(prompt_token);
},
}
},
AppRequest::Share(token) => {
ctx.copy_text(format!("{SHARE_URL_BASE}{token}"));
self.toasts.success("Share link copied");
},
AppRequest::CopyText(text) => {
ctx.copy_text(text);
self.toasts.success("Prompt copied");
},
AppRequest::Download(tokens) => self.download(&tokens),
AppRequest::Preview(url) => self.preview = Some(url),
AppRequest::LoadMoreLibrary(key) => {
let Some(user) = &self.user else {
return;
};
let (feed, name) = match key {
FeedKey::Image => (&mut self.image.feed, "image"),
FeedKey::Video => (&mut self.video.feed, "video"),
FeedKey::Library => (&mut self.library, "library"),
};
if let Some(page) = feed.next_library_page() {
self.backend.load_library(name.to_owned(), user.username.clone(), key.media_classes(), page);
}
},
}
}
fn apply_recreate(&mut self, kind: MediaKind, prompt: &Prompt) {
match kind {
MediaKind::Image => {
self.image.apply_prompt(prompt);
self.page = Page::CreateImage;
},
MediaKind::Video => {
self.video.apply_prompt(prompt);
self.page = Page::CreateVideo;
},
}
self.lightbox = None;
}
fn download(&mut self, tokens: &[String]) {
let items: Vec<FeedItem> = tokens.iter().filter_map(|t| self.find_item(t).cloned()).collect();
match items.as_slice() {
[] => {},
[item] => {
let name = file_name_for(item);
if let Some(path) = rfd::FileDialog::new().set_file_name(&name).save_file() {
self.backend.save_url(item.full_url.clone(), path);
}
},
many => {
if let Some(dir) = rfd::FileDialog::new().pick_folder() {
for item in many {
self.backend.save_url(item.full_url.clone(), dir.join(file_name_for(item)));
}
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
feed.selecting = false;
feed.selected.clear();
}
}
},
}
}
fn find_item(&self, token: &str) -> Option<&FeedItem> {
self.image.feed.find(token).or_else(|| self.video.feed.find(token)).or_else(|| self.library.find(token))
}
fn request_prompt(&mut self, prompt_token: Option<String>) {
if let Some(t) = prompt_token {
if self.requested_prompts.insert(t.clone()) {
self.backend.load_prompt(t);
}
}
}
fn open_login(&mut self) {
if self.login.is_none() {
self.login = Some(LoginDialog::default());
}
}
/// The feed the top bar's toggles act on.
fn current_feed(&mut self) -> Option<&mut FeedStore> {
match self.page {
Page::CreateImage => Some(&mut self.image.feed),
Page::CreateVideo => Some(&mut self.video.feed),
Page::Library => Some(&mut self.library),
_ => None,
}
}
// --- Frame -------------------------------------------------------------------------------
fn timers(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
if self.user.is_some() && now >= self.next_poll {
self.next_poll = now + JOB_POLL_INTERVAL;
self.backend.poll_jobs();
}
if self.user.is_some() && now >= self.next_credits {
self.next_credits = now + CREDITS_INTERVAL;
self.backend.refresh_credits();
}
if self.models_retry_at.is_some_and(|t| now >= t) {
self.models_retry_at = None;
if self.catalog.image.is_empty() {
self.backend.load_models(Modality::Image);
}
if self.catalog.video.is_empty() {
self.backend.load_models(Modality::Video);
}
}
ctx.request_repaint_after(Duration::from_secs(1));
}
/// The title bar and the sidebar (both on the window chrome).
fn shell(&mut self, ui: &mut Ui) {
let account = Account { username: self.user.as_ref().map(|u| u.username.as_str()), display_name: self.user.as_ref().map(|u| u.display_name.as_str()).filter(|n| !n.is_empty()), credits: self.credits };
let mark = creator_icons::texture(ui.ctx(), "artcraft");
let mut actions = Vec::new();
let chrome = egui::Frame::NONE.fill(theme::CHROME);
egui::Panel::top("titlebar").exact_size(shell::TITLEBAR_HEIGHT).resizable(false).frame(chrome).show(ui, |ui| {
actions.extend(shell::titlebar(ui, &account, &mark));
});
if self.sidebar_open {
egui::Panel::left("sidebar").exact_size(shell::SIDEBAR_WIDTH).resizable(false).frame(chrome).show(ui, |ui| {
actions.extend(shell::sidebar(ui, self.page, &account));
});
}
for action in actions {
self.apply_shell_action(action);
}
}
fn apply_shell_action(&mut self, action: ShellAction) {
match action {
ShellAction::Navigate(page) => {
self.page = page;
self.lightbox = None;
},
ShellAction::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
ShellAction::SignIn => self.open_login(),
ShellAction::SignOut => {
self.backend.logout();
self.settings_open = false;
},
ShellAction::OpenSettings => self.settings_open = true,
ShellAction::SetViewMode(mode) => self.view_mode = mode,
ShellAction::ToggleSelect => {
if let Some(feed) = self.current_feed() {
feed.selecting = !feed.selecting;
feed.selected.clear();
}
},
}
}
/// The content panel: inset from the window edges with rounded corners, its header on top
/// and the page below.
fn page_ui(&mut self, ui: &mut Ui) {
let ctx = ui.ctx().clone();
let gap = shell::PANEL_GAP;
let margin = egui::Margin { left: if self.sidebar_open { 0 } else { gap }, right: gap, top: 0, bottom: gap };
let toggles = match self.page {
Page::CreateImage => Some((self.view_mode, self.image.feed.selecting)),
Page::CreateVideo => Some((self.view_mode, self.video.feed.selecting)),
Page::Library => Some((self.view_mode, self.library.selecting)),
_ => None,
};
let mut header_action = None;
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(theme::CHROME).inner_margin(margin)).show(ui, |ui| {
let panel = egui::Frame::new().fill(theme::BG).stroke(theme::hairline()).corner_radius(shell::PANEL_RADIUS);
panel.show(ui, |ui| {
ui.set_min_size(ui.available_size());
egui::Panel::top("page-header").exact_size(shell::HEADER_HEIGHT).resizable(false).frame(egui::Frame::NONE).show(ui, |ui| {
header_action = shell::page_header(ui, self.page, toggles);
});
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
let mut env = env!(self, ctx);
match self.page {
Page::Home => {
if let Some(page) = other::home(ui, env.signed_in, env.requests) {
self.page = page;
}
},
Page::CreateImage => self.image.ui(ui, &mut env),
Page::CreateVideo => self.video.ui(ui, &mut env),
Page::Library => other::library(ui, &mut env, &mut self.library),
page => other::coming_soon(ui, page),
}
});
});
});
if let Some(action) = header_action {
self.apply_shell_action(action);
}
self.prefetch_list_prompts();
}
/// The list view shows prompt texts: fetch the records for the visible feed, a few at a time.
fn prefetch_list_prompts(&mut self) {
if self.view_mode != ViewMode::List {
return;
}
let feed = match self.page {
Page::CreateImage => &self.image.feed,
Page::CreateVideo => &self.video.feed,
Page::Library => &self.library,
_ => return,
};
let wanted: Vec<String> = feed.items.iter().filter_map(|i| i.prompt_token.clone()).filter(|t| !self.requested_prompts.contains(t)).take(PROMPT_FETCHES_PER_FRAME).collect();
for token in wanted {
self.request_prompt(Some(token));
}
}
fn overlays(&mut self, ctx: &egui::Context) {
if let Some(lb) = &mut self.lightbox {
let feed = match lb.kind {
_ if self.page == Page::Library => &self.library,
MediaKind::Image => &self.image.feed,
MediaKind::Video => &self.video.feed,
};
let order = feed.ordered_tokens();
let item = self.image.feed.find(&lb.token).or_else(|| self.video.feed.find(&lb.token)).or_else(|| self.library.find(&lb.token)).cloned();
match item {
Some(item) => {
let prompt = item.prompt_token.as_ref().and_then(|t| self.prompt_records.get(t));
let actions = lb.show(ctx, &item, &order, prompt, &self.catalog, &mut self.cache);
for action in actions {
self.lightbox_action(ctx, action, &item);
}
},
None => self.lightbox = None,
}
}
if let Some(picker) = &mut self.picker {
for action in picker.show(ctx, &mut self.cache) {
match action {
PickerAction::Close => self.picker = None,
PickerAction::LoadMore => {
if let (Some(p), Some(user)) = (self.picker.as_mut(), self.user.as_ref()) {
if !p.loading {
p.loading = true;
self.backend.load_library("picker".to_owned(), user.username.clone(), p.media_class(), p.next_page);
}
}
},
PickerAction::Confirm(picks) => {
if let Some(p) = self.picker.take() {
let refs = if p.page == MediaKind::Image { &mut self.image.refs } else { &mut self.video.refs };
common::attach_from_library(refs, p.kind, p.slot, picks);
}
},
}
if self.picker.is_none() {
break;
}
}
}
if let Some(login) = &mut self.login {
match login.show(ctx) {
Some(LoginAction::Close) => self.login = None,
Some(LoginAction::Password) => {
login.busy = true;
login.error = None;
self.backend.login_with_password(login.username.trim().to_owned(), std::mem::take(&mut login.password));
},
Some(LoginAction::Browser) => {
login.error = None;
self.backend.login_with_browser();
},
Some(LoginAction::CancelBrowser) => {
self.backend.cancel_browser_login();
login.device = None;
},
None => {},
}
}
if self.settings_open {
let username = self.user.as_ref().map(|u| u.username.clone());
match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, self.backend.data_root()) {
Some(SettingsAction::Close) => self.settings_open = false,
Some(SettingsAction::SignOut) => {
self.settings_open = false;
self.backend.logout();
},
Some(SettingsAction::SignIn) => {
self.settings_open = false;
self.open_login();
},
None => {},
}
}
if let Some(url) = self.preview.clone() {
let close = widgets::modal(ctx, Id::new("preview"), (ctx.content_rect().width() * 0.9).min(1100.0), |ui| {
let bounds = egui::vec2(ui.available_width(), ctx.content_rect().height() * 0.8);
match self.cache.get(ctx, &url) {
crate::backend::media_cache::Lookup::Ready(t) => {
let size = fit_inside(t.size_vec2(), bounds);
ui.vertical_centered(|ui| ui.add(egui::Image::new(&t).fit_to_exact_size(size)));
},
_ => {
ui.add_sized(bounds, egui::Spinner::new());
},
}
});
if close {
self.preview = None;
}
}
}
fn lightbox_action(&mut self, ctx: &egui::Context, action: LightboxAction, item: &FeedItem) {
match action {
LightboxAction::Close => self.lightbox = None,
LightboxAction::Navigate(token) => {
if let Some(next) = self.find_item(&token).cloned() {
self.request_prompt(next.prompt_token);
}
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
if feed.find(&token).is_some() {
feed.last_viewed = Some(token.clone());
}
}
if let Some(lb) = &mut self.lightbox {
lb.token = token;
}
},
LightboxAction::Recreate => self.handle_request(ctx, AppRequest::Recreate { kind: item.kind, token: item.token.clone() }),
LightboxAction::MakeVideo => self.handle_request(ctx, AppRequest::MakeVideo { token: item.token.clone() }),
LightboxAction::Download => self.download(std::slice::from_ref(&item.token)),
LightboxAction::Share => self.handle_request(ctx, AppRequest::Share(item.token.clone())),
LightboxAction::Delete => {
self.backend.delete_media(item.token.clone());
self.lightbox = None;
},
LightboxAction::CopyPrompt(text) => self.handle_request(ctx, AppRequest::CopyText(text)),
LightboxAction::Play(url) => {
if let Err(err) = open::that(&url) {
self.toasts.error(format!("Couldn't open the video: {err}"));
}
},
}
}
}
impl eframe::App for ArtcraftApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
for event in self.backend.drain() {
self.handle_event(ctx, event);
}
self.timers(ctx);
}
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.cache.poll(&ctx);
self.shell(ui);
self.page_ui(ui);
for request in std::mem::take(&mut self.requests) {
self.handle_request(&ctx, request);
}
self.overlays(&ctx);
self.toasts.show(&ctx);
window::window_edges(&ctx);
if let Some(shot) = &mut self.screenshot {
shot.tick(&ctx);
}
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
let saved = Saved { page: self.page, enter_to_generate: self.enter_to_generate, view_mode: self.view_mode, sidebar_open: self.sidebar_open, image: self.image.settings.clone(), video: self.video.settings.clone() };
eframe::set_value(storage, STORAGE_KEY, &saved);
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
theme::CHROME.to_normalized_gamma_f32()
}
}
/// Diagnostics: `ARTCRAFT_SCREENSHOT=<file.png>` saves a screenshot once the window has settled
/// (and `ARTCRAFT_SCREENSHOT_EXIT=1` then closes the app), for checking the UI headlessly.
struct ScreenshotRequest {
path: std::path::PathBuf,
exit: bool,
frames: u32,
requested: bool,
}
impl ScreenshotRequest {
/// Frames to wait before capturing (models, session and thumbnails get a moment to load).
const SETTLE_SECS: f64 = 8.0;
fn from_env() -> Option<Self> {
let path = std::env::var_os("ARTCRAFT_SCREENSHOT")?.into();
Some(Self { path, exit: std::env::var_os("ARTCRAFT_SCREENSHOT_EXIT").is_some(), frames: 0, requested: false })
}
fn tick(&mut self, ctx: &egui::Context) {
self.frames += 1;
ctx.request_repaint();
if !self.requested && ctx.input(|i| i.time) >= Self::SETTLE_SECS {
self.requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
}
let image = ctx.input(|i| {
i.raw.events.iter().find_map(|e| match e {
egui::Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
});
if let Some(image) = image {
let [w, h] = image.size;
let pixels: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
match image::RgbaImage::from_raw(w as u32, h as u32, pixels).map(|img| img.save(&self.path)) {
Some(Ok(())) => log::info!("Saved a screenshot to {}", self.path.display()),
other => log::error!("Couldn't save the screenshot: {other:?}"),
}
if self.exit {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
}
/// `artcraft_<token>.<ext>`, the extension taken from the URL.
fn file_name_for(item: &FeedItem) -> String {
let ext = item.full_url.rsplit('.').next().filter(|e| e.len() <= 4 && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or(if item.kind == MediaKind::Video { "mp4" } else { "png" });
format!("artcraft_{}.{ext}", item.token)
}

View File

@ -0,0 +1,153 @@
//! Images shown in the UI: CDN thumbnails and local previews, as GPU textures.
//!
//! Downloads run on the tokio runtime (a few at a time) and decode off the UI thread; the UI
//! uploads finished images in [`MediaCache::poll`]. Textures nobody has drawn for a while are
//! dropped once the cache grows past its budget, so long feeds don't hold every image forever.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use egui::{ColorImage, TextureHandle, TextureOptions};
use log::warn;
use tokio::runtime::Handle;
use tokio::sync::Semaphore;
/// How many images may download at once.
const MAX_CONCURRENT_DOWNLOADS: usize = 6;
/// Longest side of a decoded image; larger ones are downscaled before upload.
const MAX_DECODED_SIDE: u32 = 2048;
/// Textures kept even when unused (roughly two screens of thumbnails).
const SOFT_CAPACITY: usize = 160;
/// Frames an image may go undrawn before it can be evicted.
const EVICT_AFTER_FRAMES: u64 = 600;
pub struct MediaCache {
rt: Handle,
http: reqwest::Client,
limiter: Arc<Semaphore>,
tx: Sender<Loaded>,
rx: Receiver<Loaded>,
entries: HashMap<String, Entry>,
frame: u64,
}
/// What [`MediaCache::get`] knows about an image.
pub enum Lookup {
Loading,
Ready(TextureHandle),
Failed,
}
enum Entry {
Loading { last_used: u64 },
Ready { texture: TextureHandle, last_used: u64 },
Failed,
}
struct Loaded {
key: String,
image: Option<ColorImage>,
}
impl MediaCache {
pub fn new(rt: Handle, http: reqwest::Client) -> Self {
let (tx, rx) = channel();
Self { rt, http, limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)), tx, rx, entries: HashMap::new(), frame: 0 }
}
/// The texture for `url`, starting its download the first time it's asked for.
pub fn get(&mut self, ctx: &egui::Context, url: &str) -> Lookup {
let frame = self.frame;
match self.entries.get_mut(url) {
Some(Entry::Ready { texture, last_used }) => {
*last_used = frame;
Lookup::Ready(texture.clone())
},
Some(Entry::Loading { last_used }) => {
*last_used = frame;
Lookup::Loading
},
Some(Entry::Failed) => Lookup::Failed,
None => {
self.entries.insert(url.to_owned(), Entry::Loading { last_used: frame });
self.spawn_download(ctx, url.to_owned());
Lookup::Loading
},
}
}
/// Decodes `bytes` off the UI thread and registers the result under `key`.
pub fn insert_bytes(&mut self, ctx: &egui::Context, key: &str, bytes: Arc<[u8]>) {
if self.entries.contains_key(key) {
return;
}
self.entries.insert(key.to_owned(), Entry::Loading { last_used: self.frame });
let (tx, ctx, key) = (self.tx.clone(), ctx.clone(), key.to_owned());
self.rt.spawn_blocking(move || {
let image = decode(&bytes);
let _ = tx.send(Loaded { key, image });
ctx.request_repaint();
});
}
/// Uploads finished downloads and evicts stale textures. Call once per frame.
pub fn poll(&mut self, ctx: &egui::Context) {
self.frame += 1;
while let Ok(Loaded { key, image }) = self.rx.try_recv() {
// Dropped while loading (evicted): don't resurrect it.
let Some(entry) = self.entries.get_mut(&key) else {
continue;
};
*entry = match image {
Some(image) => Entry::Ready { texture: ctx.load_texture(&key, image, TextureOptions::LINEAR), last_used: self.frame },
None => Entry::Failed,
};
}
if self.entries.len() > SOFT_CAPACITY {
let frame = self.frame;
self.entries.retain(|_, e| match e {
Entry::Ready { last_used, .. } | Entry::Loading { last_used } => frame.saturating_sub(*last_used) < EVICT_AFTER_FRAMES,
Entry::Failed => true,
});
}
}
fn spawn_download(&self, ctx: &egui::Context, url: String) {
let (http, limiter, tx, ctx) = (self.http.clone(), self.limiter.clone(), self.tx.clone(), ctx.clone());
self.rt.spawn(async move {
let image = match limiter.acquire_owned().await {
Ok(_permit) => download(&http, &url).await,
Err(_) => None,
};
let _ = tx.send(Loaded { key: url, image });
ctx.request_repaint();
});
}
}
async fn download(http: &reqwest::Client, url: &str) -> Option<ColorImage> {
let response = match http.get(url).send().await.and_then(|r| r.error_for_status()) {
Ok(response) => response,
Err(err) => {
warn!("Image download failed for {url}: {err}");
return None;
},
};
let bytes = response.bytes().await.ok()?;
tokio::task::spawn_blocking(move || decode(&bytes)).await.ok().flatten()
}
fn decode(bytes: &[u8]) -> Option<ColorImage> {
let image = match image::load_from_memory(bytes) {
Ok(image) => image,
Err(err) => {
warn!("Image decode failed: {err}");
return None;
},
};
let image = if image.width().max(image.height()) > MAX_DECODED_SIDE { image.thumbnail(MAX_DECODED_SIDE, MAX_DECODED_SIDE) } else { image };
let rgba = image.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
Some(ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()))
}

View File

@ -0,0 +1,734 @@
//! Everything that talks to the ArtCraft API. Calls run on a tokio runtime and report back as
//! [`Event`]s, which the UI drains once per frame; nothing here blocks the UI thread.
pub mod media_cache;
pub mod session;
pub mod wire;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use artcraft_api_defs::users::login_challenges::LoginChallengeState;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file;
use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{ImageType, UploadImageBytesArgs, upload_image_media_file_from_bytes};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{UploadImageFromFileArgs, upload_image_media_file_from_file};
use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{UploadVideoFromFileArgs, upload_video_media_file_from_file};
use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{OmniGenListImageModelsArgs, omni_gen_list_image_models};
use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{OmniGenListVideoModelsArgs, omni_gen_list_video_models};
use artcraft_client::endpoints::users::password_login::PasswordLoginRequest;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use artcraft_client::utils::basic_json_post_request::basic_json_post_request;
use artcraft_client::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use enums::common::payments_namespace::PaymentsNamespace;
use log::{error, warn};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use tokens::tokens::media_files::MediaFileToken;
use crate::feed::types::MediaKind;
use crate::models::{self, ModelInfo};
use session::Credentials;
use wire::{BatchMedia, MediaFile, PromptResponse, SessionInfo, SessionJobs, SessionUser, UploadResponse, UserMediaList};
/// Items per library page (`useGalleryData`'s `PAGE_SIZE`).
pub const LIBRARY_PAGE_SIZE: u32 = 40;
/// Model listings are retried a few times before giving up (`list_*_models_command`).
const MODEL_LIST_ATTEMPTS: u32 = 3;
const MODEL_LIST_BACKOFF: Duration = Duration::from_millis(250);
/// The share link base (`SHARE_URL_BASE`).
pub const SHARE_URL_BASE: &str = "https://getartcraft.com/media/";
/// Sent on every request (`artcraft_client_identity::ARTCRAFT_DESKTOP_USER_AGENT`).
const USER_AGENT: &str = "storyteller-client/1.0";
/// Which media an operation is for (OmniGen's modality in the URL).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Modality {
Image,
Video,
}
impl Modality {
fn path(self) -> &'static str {
match self {
Modality::Image => "image",
Modality::Video => "video",
}
}
}
impl From<MediaKind> for Modality {
fn from(kind: MediaKind) -> Self {
match kind {
MediaKind::Image => Modality::Image,
MediaKind::Video => Modality::Video,
}
}
}
/// A result coming back from the backend.
pub enum Event {
/// The session check finished: `Some` when signed in.
Session(Option<SessionUser>),
LoginFailed(String),
/// A browser sign-in started: show the code while we wait.
DeviceLogin {
url: String,
code: String,
},
Credits(u64),
Models(Modality, Result<Vec<ModelInfo>, String>),
/// A cost estimate for the request identified by `key`.
Cost {
key: String,
credits: Option<u64>,
},
Enqueued {
modality: Modality,
job_tokens: Vec<String>,
meta: EnqueueMeta,
},
EnqueueFailed {
modality: Modality,
message: String,
},
Uploaded {
ref_id: u64,
result: Result<UploadedMedia, String>,
},
Jobs(Vec<wire::Job>),
LibraryPage {
key: String,
page: u32,
items: Vec<MediaFile>,
total_pages: u32,
},
LibraryFailed {
key: String,
message: String,
},
Batch {
job_token: String,
items: Vec<MediaFile>,
},
Prompt(wire::Prompt),
Deleted(String),
Saved(PathBuf),
Toast {
error: bool,
message: String,
},
}
/// What the UI remembers about a request it just sent, to label the pending card.
#[derive(Clone, Debug)]
pub struct EnqueueMeta {
pub prompt: String,
pub model_id: String,
pub batch_count: u32,
pub ref_image: Option<String>,
}
/// A finished upload: the media token, plus what the server knows about the file.
#[derive(Clone, Debug)]
pub struct UploadedMedia {
pub token: String,
pub duration_secs: f32,
pub thumbnail: Option<String>,
pub full_url: Option<String>,
}
pub struct Backend {
rt: tokio::runtime::Runtime,
ctx: egui::Context,
tx: Sender<Event>,
rx: Receiver<Event>,
host: ApiHost,
root: PathBuf,
creds: Arc<RwLock<Option<Credentials>>>,
http: reqwest::Client,
device_login_cancel: Arc<AtomicBool>,
}
impl Backend {
pub fn new(ctx: egui::Context) -> Self {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(4).thread_name("artcraft-io").enable_all().build().expect("tokio runtime");
let (tx, rx) = channel();
let root = session::data_root();
let host = session::api_host(&root);
let creds = session::load(&root);
let http = reqwest::Client::builder().user_agent(USER_AGENT).gzip(true).timeout(Duration::from_secs(60)).build().unwrap_or_default();
Self { rt, ctx, tx, rx, host, root, creds: Arc::new(RwLock::new(creds)), http, device_login_cancel: Arc::new(AtomicBool::new(false)) }
}
pub fn runtime(&self) -> tokio::runtime::Handle {
self.rt.handle().clone()
}
/// A plain HTTP client for CDN downloads (thumbnails, files).
pub fn http(&self) -> reqwest::Client {
self.http.clone()
}
pub fn data_root(&self) -> &Path {
&self.root
}
/// Events that arrived since the last call.
pub fn drain(&self) -> Vec<Event> {
self.rx.try_iter().collect()
}
// --- Session -----------------------------------------------------------------------------
/// Checks who's signed in (`GET /v1/session`), then fetches their credits.
pub fn refresh_session(&self) {
let api = self.api();
self.spawn(move |tx| async move {
if api.creds.is_none() {
let _ = tx.send(Event::Session(None));
return;
}
match api.get_json::<SessionInfo>("/v1/session").await {
Ok(info) if info.logged_in => {
let _ = tx.send(Event::Session(info.user));
api.send_credits(&tx).await;
},
Ok(_) => {
let _ = tx.send(Event::Session(None));
},
Err(err) => {
warn!("Session check failed: {err}");
let _ = tx.send(Event::Session(None));
let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't reach ArtCraft: {err}") });
},
}
});
}
pub fn refresh_credits(&self) {
let api = self.api();
self.spawn(move |tx| async move { api.send_credits(&tx).await });
}
/// Signs in with a username (or email) and password (`POST /v1/login`).
pub fn login_with_password(&self, username_or_email: String, password: String) {
let (host, root, creds, ctx) = (self.host.clone(), self.root.clone(), self.creds.clone(), self.ctx.clone());
let this = self.handle();
self.spawn(move |tx| async move {
let result = async {
let client = LoginChallengeClient::new(&host).map_err(login_error)?;
let (response, set_cookie) = client.password_login(&PasswordLoginRequest { username_or_email, password }).await.map_err(login_error)?;
response.signed_session.filter(|s| !s.is_empty()).or_else(|| set_cookie.as_deref().and_then(session_from_set_cookie)).ok_or_else(|| "The server didn't return a session.".to_owned())
}
.await;
match result {
Ok(session) => {
store_session(&root, &creds, session);
this.refresh_session();
},
Err(message) => {
let _ = tx.send(Event::LoginFailed(message));
},
}
ctx.request_repaint();
});
}
/// Signs in through the browser: shows a code, opens the approval page, and waits.
pub fn login_with_browser(&self) {
self.device_login_cancel.store(false, Ordering::Relaxed);
let (host, root, creds, cancel) = (self.host.clone(), self.root.clone(), self.creds.clone(), self.device_login_cancel.clone());
let this = self.handle();
self.spawn(move |tx| async move {
let client = match LoginChallengeClient::new(&host) {
Ok(c) => c,
Err(err) => {
let _ = tx.send(Event::LoginFailed(login_error(err)));
return;
},
};
let challenge = match client.create().await {
Ok(c) => c,
Err(err) => {
let _ = tx.send(Event::LoginFailed(login_error(err)));
return;
},
};
let _ = tx.send(Event::DeviceLogin { url: challenge.verification_url.clone(), code: challenge.confirmation_code.clone() });
if let Err(err) = open::that(&challenge.verification_url) {
warn!("Couldn't open the browser: {err}");
}
let interval = Duration::from_secs(u64::from(challenge.poll_interval_seconds.max(1)));
loop {
tokio::time::sleep(interval).await;
if cancel.load(Ordering::Relaxed) {
return;
}
if chrono::Utc::now() > challenge.expires_at {
let _ = tx.send(Event::LoginFailed("The sign-in code expired. Please try again.".to_owned()));
return;
}
match client.poll(&challenge.device_token).await {
Ok(result) => {
let signed = result.response.maybe_signed_session.or_else(|| result.session_set_cookie.as_deref().and_then(session_from_set_cookie));
if let Some(session) = signed {
store_session(&root, &creds, session);
this.refresh_session();
return;
}
if result.response.status == LoginChallengeState::Failed {
let _ = tx.send(Event::LoginFailed("Sign-in was declined or failed.".to_owned()));
return;
}
},
Err(err) => warn!("Login poll failed: {}", login_error(err)),
}
}
});
}
pub fn cancel_browser_login(&self) {
self.device_login_cancel.store(true, Ordering::Relaxed);
}
/// Signs out on the server (best effort) and forgets the session here.
pub fn logout(&self) {
let api = self.api();
let (root, creds) = (self.root.clone(), self.creds.clone());
if let Ok(mut c) = creds.write() {
*c = None;
}
session::delete(&root);
self.spawn(move |tx| async move {
if let Some(set) = api.creds.as_ref() {
let result: Result<Value, _> = basic_json_post_request(&api.host, "/v1/logout", Some(set), serde_json::json!({})).await;
if let Err(err) = result {
warn!("Logout request failed: {err}");
}
}
let _ = tx.send(Event::Session(None));
});
}
// --- Models and costs --------------------------------------------------------------------
/// Fetches the OmniGen model listing for `modality`.
pub fn load_models(&self, modality: Modality) {
let host = self.host.clone();
self.spawn(move |tx| async move {
let mut last_err = String::new();
for attempt in 0..MODEL_LIST_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(MODEL_LIST_BACKOFF).await;
}
let result = match modality {
Modality::Image => omni_gen_list_image_models(OmniGenListImageModelsArgs { api_host: &host, maybe_creds: None, provider: None }).await.map(|r| r.models.iter().map(models::image_model).collect::<Vec<_>>()),
Modality::Video => omni_gen_list_video_models(OmniGenListVideoModelsArgs { api_host: &host, maybe_creds: None, provider: None }).await.map(|r| r.models.iter().map(models::video_model).collect::<Vec<_>>()),
};
match result {
Ok(models) => {
let _ = tx.send(Event::Models(modality, Ok(models)));
return;
},
Err(err) => last_err = api_message(&err),
}
}
error!("Listing {modality:?} models failed: {last_err}");
let _ = tx.send(Event::Models(modality, Err(last_err)));
});
}
/// Asks what a request would cost (`POST /v1/omni_gen/cost/{modality}`).
pub fn estimate_cost(&self, modality: Modality, key: String, fields: Map<String, Value>) {
let api = self.api();
self.spawn(move |tx| async move {
let path = format!("/v1/omni_gen/cost/{}", modality.path());
let credits = match basic_json_post_request::<_, Value>(&api.host, &path, api.creds.as_ref(), fields).await {
Ok(response) => response.get("cost_in_credits").and_then(Value::as_u64),
Err(err) => {
warn!("Cost estimate failed: {}", api_message(&err));
None
},
};
let _ = tx.send(Event::Cost { key, credits });
});
}
/// Starts a generation (`POST /v1/omni_gen/generate/{modality}`).
pub fn generate(&self, modality: Modality, mut fields: Map<String, Value>, meta: EnqueueMeta) {
let api = self.api();
self.spawn(move |tx| async move {
let Some(creds) = api.creds.as_ref() else {
let _ = tx.send(Event::EnqueueFailed { modality, message: "Please sign in to generate.".to_owned() });
return;
};
fields.entry("idempotency_token").or_insert_with(|| Value::String(uuid::Uuid::new_v4().to_string()));
let path = format!("/v1/omni_gen/generate/{}", modality.path());
match basic_json_post_request::<_, Value>(&api.host, &path, Some(creds), fields).await {
Ok(response) => {
let tokens = job_tokens(&response);
if tokens.is_empty() {
let _ = tx.send(Event::EnqueueFailed { modality, message: "The server didn't start a job.".to_owned() });
} else {
let _ = tx.send(Event::Enqueued { modality, job_tokens: tokens, meta });
}
api.send_credits(&tx).await;
},
Err(err) => {
let _ = tx.send(Event::EnqueueFailed { modality, message: api_message(&err) });
},
}
});
}
// --- Uploads -----------------------------------------------------------------------------
/// Uploads a reference file from disk; images, MP4 videos and audio.
pub fn upload_file(&self, ref_id: u64, kind: crate::prompt_box::refs::RefKind, path: PathBuf) {
use crate::prompt_box::refs::RefKind;
let api = self.api();
self.spawn(move |tx| async move {
let token = match kind {
RefKind::Image => upload_image_media_file_from_file(UploadImageFromFileArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), path: &path, is_intermediate_system_file: true, maybe_prompt_token: None, maybe_generation_provider: None, maybe_batch_token: None }).await.map(|r| r.media_file_token.to_string()).map_err(|e| api_message(&e)),
RefKind::Video => upload_video_media_file_from_file(UploadVideoFromFileArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), path: &path, maybe_prompt_token: None, maybe_generation_provider: None }).await.map(|r| r.media_file_token.to_string()).map_err(|e| api_error_message(&e)),
RefKind::Audio => api.upload_audio(&path).await,
};
let result = match token {
Ok(token) => Ok(api.describe_upload(token).await),
Err(message) => Err(message),
};
let _ = tx.send(Event::Uploaded { ref_id, result });
});
}
/// Uploads a pasted image (PNG bytes).
pub fn upload_png(&self, ref_id: u64, png: Vec<u8>) {
let api = self.api();
self.spawn(move |tx| async move {
let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), image_bytes: png, image_type: ImageType::Png, is_intermediate_system_file: true, maybe_generation_provider: None }).await;
let result = match result {
Ok(r) => Ok(api.describe_upload(r.media_file_token.to_string()).await),
Err(err) => Err(api_message(&err)),
};
let _ = tx.send(Event::Uploaded { ref_id, result });
});
}
// --- Feed --------------------------------------------------------------------------------
/// Polls the session's recent jobs (`GET /v1/jobs/session`).
pub fn poll_jobs(&self) {
let api = self.api();
if api.creds.is_none() {
return;
}
self.spawn(move |tx| async move {
match api.get_json::<SessionJobs>("/v1/jobs/session").await {
Ok(jobs) => {
let _ = tx.send(Event::Jobs(jobs.jobs));
},
Err(err) => warn!("Polling jobs failed: {err}"),
}
});
}
/// Loads one page of the user's library, filtered to `classes` (e.g. `image`, `video`).
pub fn load_library(&self, key: String, username: String, classes: &'static str, page: u32) {
let api = self.api();
self.spawn(move |tx| async move {
let path = format!("/v1/media_files/list/user/{username}?filter_media_classes={classes}&page_size={LIBRARY_PAGE_SIZE}&page_index={page}");
match api.get_json::<UserMediaList>(&path).await {
Ok(list) => {
let total_pages = list.pagination.map_or(0, |p| p.total_page_count);
let items = list.results.into_iter().filter(|m| m.media_type != "scene_json" && m.origin_category != "upload").collect();
let _ = tx.send(Event::LibraryPage { key, page, items, total_pages });
},
Err(message) => {
let _ = tx.send(Event::LibraryFailed { key, message });
},
}
});
}
/// Lists every file a batch job produced.
pub fn load_batch(&self, job_token: String, batch_token: String) {
let api = self.api();
self.spawn(move |tx| async move {
match api.get_json::<BatchMedia>(&format!("/v1/media_files/batch_gen_redux/{batch_token}")).await {
Ok(batch) => {
let _ = tx.send(Event::Batch { job_token, items: batch.media_files });
},
Err(err) => warn!("Loading batch {batch_token} failed: {err}"),
}
});
}
/// Fetches the prompt record behind a generation (for Recreate and the list view).
pub fn load_prompt(&self, prompt_token: String) {
let api = self.api();
self.spawn(move |tx| async move {
match api.get_json::<PromptResponse>(&format!("/v1/prompts/{prompt_token}")).await {
Ok(r) if r.success => {
let _ = tx.send(Event::Prompt(r.prompt));
},
Ok(_) => warn!("Prompt {prompt_token} not found"),
Err(err) => warn!("Loading prompt {prompt_token} failed: {err}"),
}
});
}
pub fn delete_media(&self, token: String) {
let api = self.api();
self.spawn(move |tx| async move {
match delete_media_file(&api.host, api.creds.as_ref(), &MediaFileToken::new(token.clone())).await {
Ok(_) => {
let _ = tx.send(Event::Deleted(token));
let _ = tx.send(Event::Toast { error: false, message: "File deleted.".to_owned() });
},
Err(err) => {
let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't delete: {}", api_message(&err)) });
},
}
});
}
/// Downloads `url` to `path`.
pub fn save_url(&self, url: String, path: PathBuf) {
let http = self.http.clone();
self.spawn(move |tx| async move {
let result = async {
let bytes = http.get(&url).send().await.and_then(|r| r.error_for_status()).map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&path, &bytes).await.map_err(|e| e.to_string())
}
.await;
match result {
Ok(()) => {
let _ = tx.send(Event::Saved(path));
},
Err(err) => {
let _ = tx.send(Event::Toast { error: true, message: format!("File download failed: {err}") });
},
}
});
}
// --- Plumbing ----------------------------------------------------------------------------
fn api(&self) -> Api {
let creds = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set));
Api { host: self.host.clone(), creds, http: self.http.clone() }
}
/// A cheap handle for tasks that need to call back into the backend.
fn handle(&self) -> BackendHandle {
BackendHandle { host: self.host.clone(), creds: self.creds.clone(), http: self.http.clone(), tx: self.tx.clone(), ctx: self.ctx.clone(), rt: self.rt.handle().clone() }
}
fn spawn<F, Fut>(&self, task: F)
where
F: FnOnce(Sender<Event>) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let (tx, ctx) = (self.tx.clone(), self.ctx.clone());
self.rt.spawn(async move {
task(tx).await;
ctx.request_repaint();
});
}
}
/// Enough of the backend to refresh the session from inside a task.
#[derive(Clone)]
struct BackendHandle {
host: ApiHost,
creds: Arc<RwLock<Option<Credentials>>>,
http: reqwest::Client,
tx: Sender<Event>,
ctx: egui::Context,
rt: tokio::runtime::Handle,
}
impl BackendHandle {
fn refresh_session(&self) {
let creds = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set));
let api = Api { host: self.host.clone(), creds, http: self.http.clone() };
let (tx, ctx) = (self.tx.clone(), self.ctx.clone());
self.rt.spawn(async move {
match api.get_json::<SessionInfo>("/v1/session").await {
Ok(info) if info.logged_in => {
let _ = tx.send(Event::Session(info.user));
api.send_credits(&tx).await;
},
Ok(_) => {
let _ = tx.send(Event::LoginFailed("The session wasn't accepted. Please try again.".to_owned()));
},
Err(err) => {
let _ = tx.send(Event::LoginFailed(err));
},
}
ctx.request_repaint();
});
}
}
/// One request's worth of host, credentials and HTTP client.
struct Api {
host: ApiHost,
creds: Option<StorytellerCredentialSet>,
http: reqwest::Client,
}
impl Api {
/// A GET with the desktop app's identity headers and cookies, decoded leniently.
async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
let url = format!("{}{path}", self.host.to_api_hostname_and_scheme());
let mut request = self.http.get(&url).header("Accept", "application/json").header("Origin", self.host.request_origin());
if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) {
request = request.header("Cookie", cookie);
}
let response = request.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(body_message(&body).unwrap_or_else(|| format!("HTTP {status}")));
}
serde_json::from_str(&body).map_err(|e| format!("Unexpected response from {path}: {e}"))
}
async fn send_credits(&self, tx: &Sender<Event>) {
match get_session_credits(&self.host, self.creds.as_ref(), PaymentsNamespace::Artcraft).await {
Ok(c) => {
let _ = tx.send(Event::Credits(c.sum_total_credits));
},
Err(err) => warn!("Credits check failed: {}", api_message(&err)),
}
}
/// `POST /v1/media_files/upload/audio` (no client function for audio yet).
async fn upload_audio(&self, path: &Path) -> Result<String, String> {
let bytes = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("audio").to_owned();
let form = reqwest::multipart::Form::new().text("uuid_idempotency_token", uuid::Uuid::new_v4().to_string()).text("maybe_visibility", "public").part("file", reqwest::multipart::Part::bytes(bytes).file_name(name));
let url = format!("{}/v1/media_files/upload/audio", self.host.to_api_hostname_and_scheme());
let mut request = self.http.post(url).header("Accept", "application/json").header("Origin", self.host.request_origin()).multipart(form);
if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) {
request = request.header("Cookie", cookie);
}
let response = request.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(body_message(&body).unwrap_or_else(|| format!("HTTP {status}")));
}
let upload: UploadResponse = serde_json::from_str(&body).map_err(|e| e.to_string())?;
if upload.media_file_token.is_empty() {
return Err("The server didn't return a media token.".to_owned());
}
Ok(upload.media_file_token)
}
/// Looks up an uploaded file's duration and links (best effort).
async fn describe_upload(&self, token: String) -> UploadedMedia {
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct FileResponse {
media_file: MediaFile,
}
let info = self.get_json::<FileResponse>(&format!("/v1/media_files/file/{token}")).await.ok();
let file = info.map(|r| r.media_file);
UploadedMedia { duration_secs: file.as_ref().and_then(|f| f.maybe_duration_millis).map_or(0.0, |ms| ms as f32 / 1000.0), thumbnail: file.as_ref().and_then(|f| f.media_links.thumbnail(256)), full_url: file.as_ref().map(|f| f.media_links.cdn_url.clone()).filter(|u| !u.is_empty()), token }
}
}
fn store_session(root: &Path, creds: &Arc<RwLock<Option<Credentials>>>, session: String) {
let visitor = creds.read().ok().and_then(|c| c.as_ref().and_then(|c| c.visitor.clone()));
let new = Credentials { session, visitor };
session::save(root, &new);
if let Ok(mut c) = creds.write() {
*c = Some(new);
}
}
/// The `session` value out of a raw `Set-Cookie` header.
fn session_from_set_cookie(header: &str) -> Option<String> {
cookie::Cookie::parse(header).ok().filter(|c| c.name() == "session").map(|c| c.value().to_owned())
}
/// All job tokens a generate call created (`inference_job_token` plus `all_job_tokens`).
fn job_tokens(response: &Value) -> Vec<String> {
let mut tokens: Vec<String> = response.get("inference_job_token").and_then(Value::as_str).map(str::to_owned).into_iter().collect();
for t in response.get("all_job_tokens").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {
if !tokens.iter().any(|x| x == t) {
tokens.push(t.to_owned());
}
}
tokens
}
/// The server's `error_message` (or `message`) from an error body.
fn body_message(body: &str) -> Option<String> {
let v: Value = serde_json::from_str(body).ok()?;
v.get("error_message").or_else(|| v.get("message")).and_then(Value::as_str).map(str::to_owned)
}
/// A user-facing message for a client error.
pub fn api_message(err: &StorytellerError) -> String {
match err {
StorytellerError::Api(api) => api_error_message(api),
StorytellerError::Client(client) => format!("{client:?}"),
}
}
fn api_error_message(err: &ApiError) -> String {
let body = match err {
ApiError::InvalidRequest(b) | ApiError::Unauthorized(b) | ApiError::PaymentRequired(b) | ApiError::Forbidden(b) | ApiError::NotFound(b) | ApiError::TooManyRequests(b) => Some(b.as_str()),
ApiError::InternalServerError { body, .. } | ApiError::UncategorizedBadResponseWithStatusAndBody { body, .. } => Some(body.as_str()),
_ => None,
};
let fallback = match err {
ApiError::Unauthorized(_) | ApiError::Forbidden(_) => "Please sign in to ArtCraft.".to_owned(),
ApiError::PaymentRequired(_) => "You don't have enough credits for this.".to_owned(),
ApiError::TooManyRequests(_) => "Too many requests. Please wait a moment.".to_owned(),
ApiError::NetworkError(_) | ApiError::Timeout(_) => "Couldn't reach ArtCraft. Check your connection.".to_owned(),
other => format!("{other:?}"),
};
body.and_then(body_message).unwrap_or(fallback)
}
fn login_error(err: LoginChallengeClientError) -> String {
err.maybe_rejection.and_then(|r| r.error_message).unwrap_or_else(|| err.message.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collects_every_job_token_once() {
let response = serde_json::json!({ "success": true, "inference_job_token": "j1", "all_job_tokens": ["j1", "j2", "j3"] });
assert_eq!(job_tokens(&response), ["j1", "j2", "j3"]);
assert_eq!(job_tokens(&serde_json::json!({ "inference_job_token": "only" })), ["only"]);
}
#[test]
fn reads_the_servers_error_message() {
assert_eq!(body_message(r#"{"success":false,"error_message":"Not enough credits"}"#).as_deref(), Some("Not enough credits"));
assert_eq!(body_message(r#"{"message":"Origin is not allowed"}"#).as_deref(), Some("Origin is not allowed"));
assert_eq!(body_message("<html>502</html>"), None);
assert_eq!(api_error_message(&ApiError::PaymentRequired("{}".into())), "You don't have enough credits for this.");
}
#[test]
fn takes_the_session_from_set_cookie() {
assert_eq!(session_from_set_cookie("session=abc.def; Path=/; Secure").as_deref(), Some("abc.def"));
assert_eq!(session_from_set_cookie("visitor=x; Path=/"), None);
}
}

View File

@ -0,0 +1,118 @@
//! Where the signed-in session lives: our own file under `~/Artcraft/credentials`, seeded on
//! first run from the Tauri app's cookie jar so an existing ArtCraft login carries over.
use std::fs;
use std::path::{Path, PathBuf};
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use artcraft_client::utils::api_host::ApiHost;
use log::{info, warn};
use serde::{Deserialize, Serialize};
/// The Tauri app's identifier: its cookie jar lives in `<cache dir>/<identifier>/.cookies`.
const TAURI_APP_IDENTIFIER: &str = "ai.artcraft.app";
const TAURI_COOKIES_FILENAME: &str = ".cookies";
const CREDENTIALS_FILENAME: &str = "native_session.json";
/// The two ArtCraft cookies, as sent on every request (`Cookie: visitor=…; session=…`).
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct Credentials {
pub session: String,
#[serde(default)]
pub visitor: Option<String>,
}
impl Credentials {
pub fn to_set(&self) -> StorytellerCredentialSet {
StorytellerCredentialSet::initialize(self.visitor.clone().map(StorytellerAvtCookie::new), Some(StorytellerSessionCookie::new(self.session.clone())))
}
}
/// `~/Artcraft`, shared with the Tauri app (`AppDataRoot`).
pub fn data_root() -> PathBuf {
directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()).unwrap_or_else(|| PathBuf::from(".")).join("Artcraft")
}
/// The API host from `~/Artcraft/settings/env_configs.json` (production unless it says
/// `"storyteller_host": "localhost"`).
pub fn api_host(root: &Path) -> ApiHost {
#[derive(Deserialize)]
struct EnvConfigs {
storyteller_host: Option<String>,
storyteller_port: Option<u32>,
}
let path = root.join("settings").join("env_configs.json");
let configs = fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str::<EnvConfigs>(&s).ok());
match configs {
Some(EnvConfigs { storyteller_host: Some(host), storyteller_port }) if host == "localhost" => ApiHost::Localhost { port: storyteller_port.unwrap_or(12345) },
_ => ApiHost::Storyteller,
}
}
/// The saved session, or the Tauri app's (imported and saved for next time).
pub fn load(root: &Path) -> Option<Credentials> {
let path = credentials_path(root);
if let Ok(text) = fs::read_to_string(&path) {
match serde_json::from_str::<Credentials>(&text) {
Ok(creds) if !creds.session.is_empty() => return Some(creds),
Ok(_) => {},
Err(err) => warn!("Ignoring unreadable {}: {err}", path.display()),
}
}
let imported = import_tauri_cookies()?;
info!("Imported the ArtCraft login from the desktop app's cookie jar");
save(root, &imported);
Some(imported)
}
pub fn save(root: &Path, creds: &Credentials) {
let path = credentials_path(root);
let result = path.parent().map_or(Ok(()), fs::create_dir_all).and_then(|_| fs::write(&path, serde_json::to_vec_pretty(creds).unwrap_or_default()));
if let Err(err) = result {
warn!("Couldn't save the session to {}: {err}", path.display());
}
}
pub fn delete(root: &Path) {
let path = credentials_path(root);
if let Err(err) = fs::remove_file(&path) {
if err.kind() != std::io::ErrorKind::NotFound {
warn!("Couldn't delete {}: {err}", path.display());
}
}
}
fn credentials_path(root: &Path) -> PathBuf {
root.join("credentials").join(CREDENTIALS_FILENAME)
}
/// Reads `session` and `visitor` for the ArtCraft API from the Tauri HTTP plugin's jar: a JSON
/// array of `cookie_store` cookies (`{"raw_cookie": "...", "domain": {...}, "expires": {...}}`).
fn import_tauri_cookies() -> Option<Credentials> {
let path = directories::BaseDirs::new()?.cache_dir().join(TAURI_APP_IDENTIFIER).join(TAURI_COOKIES_FILENAME);
let text = fs::read_to_string(&path).ok()?;
let cookies: Vec<serde_json::Value> = serde_json::from_str(&text).ok()?;
let now = chrono::Utc::now();
let mut creds = Credentials::default();
for entry in &cookies {
let for_api = entry.get("domain").map(|d| d.to_string()).is_some_and(|d| d.contains("storyteller.ai"));
let expired = entry.pointer("/expires/AtUtc").and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).is_some_and(|t| t < now);
let Some(raw) = entry.get("raw_cookie").and_then(|v| v.as_str()) else {
continue;
};
if !for_api || expired {
continue;
}
let Ok(cookie) = cookie::Cookie::parse(raw) else {
continue;
};
match cookie.name() {
"session" => creds.session = cookie.value().to_owned(),
"visitor" => creds.visitor = Some(cookie.value().to_owned()),
_ => {},
}
}
(!creds.session.is_empty()).then_some(creds)
}

View File

@ -0,0 +1,249 @@
//! Lenient shapes for the API responses the app reads itself. Enums stay strings and every field
//! has a default, so a new server-side value can't break decoding (the typed client structs use
//! closed enums).
use serde::Deserialize;
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct MediaLinks {
pub cdn_url: String,
pub maybe_thumbnail_template: Option<String>,
pub maybe_video_previews: Option<VideoPreviews>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct VideoPreviews {
pub still: String,
pub animated: String,
pub still_thumbnail_template: String,
pub animated_thumbnail_template: String,
}
impl MediaLinks {
/// A still thumbnail `width` pixels wide: the video still frame, the resized image, or the
/// original (`thumbnail-utils.ts`).
pub fn thumbnail(&self, width: u32) -> Option<String> {
let w = width.to_string();
if let Some(previews) = &self.maybe_video_previews {
if !previews.still_thumbnail_template.is_empty() {
return Some(previews.still_thumbnail_template.replace("{WIDTH}", &w));
}
if !previews.still.is_empty() {
return Some(previews.still.clone());
}
}
match &self.maybe_thumbnail_template {
Some(template) if !template.is_empty() => Some(template.replace("{WIDTH}", &w)),
_ if !self.cdn_url.is_empty() && self.maybe_video_previews.is_none() => Some(self.cdn_url.clone()),
_ => None,
}
}
}
/// `GET /v1/media_files/list/user/{username}`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct UserMediaList {
pub success: bool,
pub results: Vec<MediaFile>,
pub pagination: Option<Pagination>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Pagination {
pub current: u32,
pub total_page_count: u32,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct MediaFile {
pub token: String,
pub media_class: Option<String>,
pub media_type: String,
pub origin_category: String,
pub maybe_batch_token: Option<String>,
pub maybe_prompt_token: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_origin_model_type: Option<String>,
pub maybe_duration_millis: Option<u64>,
pub media_links: MediaLinks,
pub created_at: String,
}
/// `GET /v1/media_files/batch_gen_redux/{token}`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct BatchMedia {
pub success: bool,
pub media_files: Vec<MediaFile>,
}
/// `GET /v1/jobs/session`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct SessionJobs {
pub success: bool,
pub jobs: Vec<Job>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct Job {
pub job_token: String,
pub request: JobRequest,
pub status: JobStatus,
pub maybe_result: Option<JobResult>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct JobRequest {
pub inference_category: String,
pub maybe_prompt_token: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_raw_inference_text: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct JobStatus {
pub status: String,
pub maybe_extra_status_description: Option<String>,
pub maybe_failure_category_updated: Option<String>,
pub maybe_failure_message: Option<String>,
pub progress_percentage: u8,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct JobResult {
pub entity_token: String,
pub maybe_batch_token: Option<String>,
pub media_links: MediaLinks,
}
/// Where a job is in its life.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JobPhase {
Running,
Succeeded,
Failed,
}
impl JobStatus {
pub fn phase(&self) -> JobPhase {
match self.status.as_str() {
"pending" | "started" => JobPhase::Running,
"complete_success" => JobPhase::Succeeded,
_ => JobPhase::Failed,
}
}
/// The user-facing failure reason (`FAILURE_REASON_LABEL`).
pub fn failure_reason(&self) -> String {
let label = match self.maybe_failure_category_updated.as_deref().unwrap_or("") {
"rule_bans_user_image" => "Image violates content policy",
"rule_bans_user_image_with_faces" => "Images with faces are not allowed",
"rule_bans_user_text_prompt" => "Text prompt violates content policy",
"rule_bans_user_content" => "Content violates content policy",
"rule_bans_generated_video" => "Generated video flagged by content policy",
"rule_bans_generated_audio" => "Generated audio flagged by content policy",
"rule_bans_generated_content" => "Generated content flagged by content policy",
"unknown" => "An unknown error occurred",
_ if self.status.starts_with("cancelled") => "Cancelled",
_ => "Generation failed",
};
label.to_owned()
}
}
/// `GET /v1/prompts/{token}`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct PromptResponse {
pub success: bool,
pub prompt: Prompt,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct Prompt {
pub token: String,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_aspect_ratio: Option<String>,
pub maybe_resolution: Option<String>,
pub maybe_duration_seconds: Option<u16>,
pub maybe_generate_audio: Option<bool>,
pub maybe_batch_count: Option<u16>,
pub maybe_context_images: Option<Vec<ContextImage>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ContextImage {
pub media_token: String,
pub semantic: String,
pub media_links: MediaLinks,
}
/// `GET /v1/session` (only the parts the app shows).
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct SessionInfo {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUser>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct SessionUser {
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
}
/// The generic `{success, media_file_token}` upload reply.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct UploadResponse {
pub success: bool,
pub media_file_token: String,
}
/// Parses an RFC 3339 timestamp into unix seconds (0 when absent or malformed).
pub fn unix_secs(timestamp: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(timestamp).map(|t| t.timestamp()).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumbnails_prefer_the_video_still_then_the_resized_image() {
let video: MediaLinks = serde_json::from_value(serde_json::json!({
"cdn_url": "https://cdn/v.mp4",
"maybe_video_previews": { "still": "https://cdn/s.jpg", "animated": "https://cdn/a.webp", "still_thumbnail_template": "https://cdn/w={WIDTH}/s.jpg", "animated_thumbnail_template": "" }
}))
.unwrap();
assert_eq!(video.thumbnail(512).as_deref(), Some("https://cdn/w=512/s.jpg"));
let image: MediaLinks = serde_json::from_value(serde_json::json!({ "cdn_url": "https://cdn/i.png", "maybe_thumbnail_template": null })).unwrap();
assert_eq!(image.thumbnail(512).as_deref(), Some("https://cdn/i.png"));
}
#[test]
fn unknown_statuses_and_fields_still_decode() {
let job: Job = serde_json::from_value(serde_json::json!({ "job_token": "j", "status": { "status": "something_new" }, "brand_new_field": 1 })).unwrap();
assert_eq!(job.status.phase(), JobPhase::Failed);
assert_eq!(job.status.failure_reason(), "Generation failed");
}
}

View File

@ -0,0 +1,531 @@
//! The generation feed as a masonry grid (`GenerationGridView`) or a list (`GenerationListView`):
//! running jobs first-class alongside failures and finished media, newest first, loading more as
//! the user scrolls.
use std::collections::{HashMap, HashSet};
use egui::{Align2, Color32, CornerRadius, FontFamily, FontId, Id, Mesh, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob, pending_status, time_ago};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::{creator_icons, widgets};
/// `react-masonry-css` gutter and the cap on tall portraits (≈ 5:7).
const GAP: f32 = 8.0;
const MAX_RATIO: f32 = 1.4;
/// Start loading the next page this far before the end.
const LOAD_MORE_MARGIN: f32 = 400.0;
const LIST_THUMB: f32 = 100.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ViewMode {
#[default]
Grid,
List,
}
/// Something the user did in the feed.
#[derive(Clone, Debug, PartialEq)]
pub enum FeedAction {
Open(String),
Recreate(String),
MakeVideo(String),
Share(String),
Download(String),
CopyPrompt(String),
DismissFailed(String),
ToggleSelect(String),
LoadMore,
}
/// Looks up a model's display name and maker for the cards' model chip.
pub trait ModelNames {
fn display_name(&self, model_id: &str) -> String;
fn creator(&self, model_id: &str) -> String;
}
pub struct FeedView<'a> {
pub id: Id,
pub mode: ViewMode,
pub pending: &'a [PendingJob],
pub failed: &'a [FailedJob],
pub items: &'a [FeedItem],
pub has_more: bool,
pub loading: bool,
/// Items that are new this session (shown even before the library catches up).
pub selecting: bool,
pub selected: &'a HashSet<String>,
pub last_viewed: Option<&'a str>,
/// Prompt texts by prompt token (list view and pending cards).
pub prompts: &'a HashMap<String, String>,
pub make_video: bool,
pub bottom_padding: f32,
}
enum Entry<'a> {
Pending(&'a PendingJob),
Failed(&'a FailedJob),
Item(&'a FeedItem),
}
impl Entry<'_> {
fn time(&self) -> i64 {
match self {
Entry::Pending(j) => j.created_at,
Entry::Failed(j) => j.updated_at,
Entry::Item(i) => i.created_at,
}
}
}
/// Measured height/width ratios of loaded thumbnails, so cards keep their shape.
#[derive(Clone, Default)]
pub struct RatioCache(HashMap<String, f32>);
pub fn show(ui: &mut Ui, view: &FeedView<'_>, cache: &mut MediaCache, ratios: &mut RatioCache, names: &dyn ModelNames) -> Vec<FeedAction> {
let mut entries: Vec<Entry<'_>> = view.pending.iter().map(Entry::Pending).chain(view.failed.iter().map(Entry::Failed)).chain(view.items.iter().map(Entry::Item)).collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.time()));
let mut actions = Vec::new();
let now = chrono::Utc::now().timestamp();
if view.pending.iter().any(|_| true) {
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
egui::ScrollArea::vertical().id_salt(view.id.with("scroll")).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let width = ui.available_width();
let origin = ui.min_rect().min;
let layout = match view.mode {
ViewMode::Grid => masonry(&entries, width, ratios),
ViewMode::List => list_layout(&entries, width),
};
let total = layout.iter().map(|r| r.bottom()).fold(0.0_f32, f32::max);
ui.allocate_exact_size(vec2(width, total + view.bottom_padding + if view.has_more { 64.0 } else { 0.0 }), Sense::hover());
for (entry, local) in entries.iter().zip(&layout) {
if !local.intersects(viewport.expand(200.0)) {
continue;
}
let rect = local.translate(origin.to_vec2());
match (entry, view.mode) {
(Entry::Pending(job), ViewMode::Grid) => pending_card(ui, rect, job, now, names),
(Entry::Failed(job), ViewMode::Grid) => failed_card(ui, view.id, rect, job, cache, names, &mut actions),
(Entry::Item(item), ViewMode::Grid) => item_card(ui, view, rect, item, cache, ratios, names, &mut actions),
(Entry::Pending(job), ViewMode::List) => pending_row(ui, rect, job, now, names),
(Entry::Failed(job), ViewMode::List) => failed_row(ui, view.id, rect, job, names, &mut actions),
(Entry::Item(item), ViewMode::List) => item_row(ui, view, rect, item, cache, names, now, &mut actions),
}
}
if view.has_more {
let spinner = Rect::from_center_size(origin + vec2(width / 2.0, total + 32.0), vec2(24.0, 24.0));
if view.loading {
icons::paint_spinner(ui, spinner, theme::MUTED);
}
if !view.loading && viewport.max.y + LOAD_MORE_MARGIN >= total {
actions.push(FeedAction::LoadMore);
}
}
});
actions
}
/// Number of columns for a width (`BREAKPOINT_COLS = {default: 4, 900: 3, 640: 2}`).
fn columns(width: f32) -> usize {
if width <= 640.0 {
2
} else if width <= 900.0 {
3
} else {
4
}
}
/// Places each entry in the shortest column, like `react-masonry-css`.
fn masonry(entries: &[Entry<'_>], width: f32, ratios: &RatioCache) -> Vec<Rect> {
let cols = columns(width);
let col_w = (width - GAP * (cols - 1) as f32) / cols as f32;
let mut heights = vec![0.0_f32; cols];
entries
.iter()
.map(|entry| {
let ratio = match entry {
Entry::Item(item) => ratios.0.get(&item.token).copied().unwrap_or(1.0).min(MAX_RATIO),
_ => 1.0,
};
let (col, y) = heights.iter().copied().enumerate().min_by(|a, b| a.1.total_cmp(&b.1)).unwrap_or((0, 0.0));
let rect = Rect::from_min_size(pos2(col as f32 * (col_w + GAP), y), vec2(col_w, col_w * ratio));
heights[col] = rect.bottom() + GAP;
rect
})
.collect()
}
/// One row per entry, centred at `max-w-5xl`.
fn list_layout(entries: &[Entry<'_>], width: f32) -> Vec<Rect> {
let w = width.min(1024.0);
let x = (width - w) / 2.0;
let row_h = LIST_THUMB + 24.0;
(0..entries.len()).map(|i| Rect::from_min_size(pos2(x, i as f32 * row_h), vec2(w, row_h))).collect()
}
fn pending_card(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn ModelNames) {
let p = ui.painter();
p.rect_filled(rect, theme::RADIUS, Color32::from_white_alpha(8));
shimmer(ui, rect);
let (progress, time_label) = pending_status(job, now);
if job.batch_count > 1 {
let banner = Rect::from_min_max(rect.min + vec2(8.0, 8.0), pos2(rect.right() - 8.0, rect.top() + 34.0));
p.rect_filled(banner, 0.0, Color32::from_black_alpha(153));
p.text(banner.center(), Align2::CENTER_CENTER, format!("Generating {} {} \u{b7} Results may appear one at a time", job.batch_count, job.kind.plural()), FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.7));
}
let c = rect.center() - vec2(0.0, 18.0);
icons::paint_spinner(ui, Rect::from_center_size(c, vec2(26.0, 26.0)), theme::fade(Color32::WHITE, 0.2));
let p = ui.painter();
p.text(c + vec2(0.0, 26.0), Align2::CENTER_CENTER, format!("{progress}%"), FontId::new(12.0, FontFamily::Monospace), theme::fade(Color32::WHITE, 0.4));
p.text(c + vec2(0.0, 42.0), Align2::CENTER_CENTER, time_label, FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.3));
let footer = bottom_gradient(ui, rect, 96.0, 178);
let text_rect = footer.shrink2(vec2(12.0, 10.0));
let galley = clamp_lines(ui, &job.prompt, 12.0, theme::fade(Color32::WHITE, 0.8), text_rect.width(), 3);
let y = text_rect.bottom() - 24.0 - galley.size().y;
ui.painter().galley(pos2(text_rect.left(), y), galley, Color32::WHITE);
let model_id = job.model_id.as_deref().unwrap_or_default();
model_chip(ui, pos2(text_rect.left(), text_rect.bottom() - 15.0), model_id, names, 10.0, theme::fade(Color32::WHITE, 0.5));
widgets::progress_bar(ui.painter(), Rect::from_min_size(pos2(text_rect.left(), text_rect.bottom() - 2.0), vec2(text_rect.width(), 4.0)), progress as f32 / 100.0);
}
fn failed_card(ui: &mut Ui, id: Id, rect: Rect, job: &FailedJob, cache: &mut MediaCache, names: &dyn ModelNames, actions: &mut Vec<FeedAction>) {
ui.painter().rect_filled(rect, theme::RADIUS, Color32::from_rgba_unmultiplied(239, 68, 68, 26));
if let Some(Lookup::Ready(t)) = job.ref_image.as_deref().map(|u| cache.get(ui.ctx(), u)) {
egui::Image::new(&t).uv(crate::prompt_box::deck::cover_uv(t.size_vec2())).tint(theme::fade(Color32::WHITE, 0.1)).paint_at(ui, rect);
}
let c = rect.center() - vec2(0.0, 20.0);
let p = ui.painter();
icons::paint(p, Rect::from_center_size(c - vec2(0.0, 18.0), vec2(24.0, 24.0)), Icon::AlertCircle, theme::BAD);
p.text(c + vec2(0.0, 6.0), Align2::CENTER_CENTER, &job.reason, FontId::new(12.0, theme::medium()), theme::BAD);
if let Some(msg) = job.message.as_deref().filter(|m| *m != job.reason) {
let galley = clamp_lines(ui, msg, 10.0, theme::fade(theme::BAD, 0.6), rect.width() - 32.0, 3);
ui.painter().galley(pos2(rect.center().x - galley.size().x / 2.0, c.y + 16.0), galley, Color32::WHITE);
}
let button = Rect::from_center_size(c + vec2(0.0, 62.0), vec2(84.0, 28.0));
let resp = ui.interact(button, id.with(("dismiss", &job.job_token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let p = ui.painter();
p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH });
let ink = if resp.hovered() { theme::fade(Color32::WHITE, 0.7) } else { theme::fade(Color32::WHITE, 0.5) };
icons::paint(p, Rect::from_center_size(button.left_center() + vec2(18.0, 0.0), vec2(12.0, 12.0)), Icon::X, ink);
p.text(button.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, "Dismiss", FontId::new(12.0, FontFamily::Proportional), ink);
if resp.clicked() {
actions.push(FeedAction::DismissFailed(job.job_token.clone()));
}
let footer = bottom_gradient(ui, rect, 56.0, 153);
let text = footer.shrink2(vec2(12.0, 10.0));
let galley = clamp_lines(ui, &job.prompt, 12.0, theme::fade(Color32::WHITE, 0.7), text.width(), 1);
ui.painter().galley(pos2(text.left(), text.bottom() - 30.0), galley, Color32::WHITE);
model_chip(ui, pos2(text.left(), text.bottom() - 12.0), job.model_id.as_deref().unwrap_or_default(), names, 10.0, theme::fade(Color32::WHITE, 0.4));
}
#[allow(clippy::too_many_arguments)]
fn item_card(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cache: &mut MediaCache, ratios: &mut RatioCache, names: &dyn ModelNames, actions: &mut Vec<FeedAction>) {
let resp = ui.interact(rect, view.id.with(("item", &item.token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let hovered = resp.hovered();
ui.painter().rect_filled(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.4));
match item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
Some(Lookup::Ready(t)) => {
let size = t.size_vec2();
if size.x > 0.0 && !ratios.0.contains_key(&item.token) {
ratios.0.insert(item.token.clone(), size.y / size.x);
}
egui::Image::new(&t).uv(cover_uv_for(size, rect.size())).corner_radius(theme::RADIUS).paint_at(ui, rect);
},
Some(Lookup::Loading) => shimmer(ui, rect),
_ => icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(28.0, 28.0)), if item.kind == MediaKind::Video { Icon::Video } else { Icon::Image }, theme::FAINT),
}
if item.kind == MediaKind::Video {
let badge = Rect::from_center_size(rect.center(), vec2(40.0, 40.0));
ui.painter().circle_filled(badge.center(), 20.0, Color32::from_black_alpha(110));
icons::paint(ui.painter(), badge.shrink(12.0).translate(vec2(1.5, 0.0)), Icon::Play, theme::fade(Color32::WHITE, 0.9));
}
let selected = view.selected.contains(&item.token);
let ring = if selected {
Some(Stroke::new(2.0, theme::ACCENT_400))
} else if hovered {
Some(Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6)))
} else if view.last_viewed == Some(item.token.as_str()) {
Some(Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.5)))
} else {
None
};
if let Some(stroke) = ring {
ui.painter().rect_stroke(rect, theme::RADIUS, stroke, egui::StrokeKind::Inside);
}
if view.selecting {
let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(20.0, 20.0));
let (fill, border) = if selected { (theme::ACCENT_400, theme::ACCENT_400) } else { (Color32::from_black_alpha(102), theme::fade(Color32::WHITE, 0.6)) };
ui.painter().rect(chip, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside);
if selected {
icons::paint(ui.painter(), chip.shrink(4.0), Icon::Check, Color32::WHITE);
}
}
if view.last_viewed == Some(item.token.as_str()) {
let galley = widgets::caps(ui, "Last viewed", 10.0, theme::medium(), 0.5, theme::fade(Color32::WHITE, 0.8));
let badge = Rect::from_min_size(pos2(rect.right() - 8.0 - galley.size().x - 26.0, rect.top() + 8.0), vec2(galley.size().x + 26.0, 18.0));
let p = ui.painter();
p.rect_filled(badge, theme::RADIUS, Color32::from_black_alpha(178));
icons::paint(p, Rect::from_center_size(badge.left_center() + vec2(11.0, 0.0), vec2(11.0, 11.0)), Icon::Eye, theme::fade(Color32::WHITE, 0.8));
p.galley(pos2(badge.left() + 20.0, badge.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
}
let mut action_clicked = false;
// The pointer may be on an action button rather than the card itself.
if (hovered || ui.rect_contains_pointer(rect)) && !view.selecting {
let footer = bottom_gradient(ui, rect, 56.0, 178);
let chip_y = footer.bottom() - 18.0;
let mut x = footer.left() + 8.0;
x = chip(ui, pos2(x, chip_y), Some(if item.kind == MediaKind::Video { Icon::Video } else { Icon::Image }), None, item.kind.label());
if let Some(model) = item.model_id.as_deref() {
chip(ui, pos2(x + 6.0, chip_y), None, Some(&names.creator(model)), &truncate(&names.display_name(model), 16));
}
let mut buttons: Vec<(Icon, &str, FeedAction)> = Vec::new();
if item.prompt_token.is_some() {
buttons.push((Icon::RotateCw, "Recreate", FeedAction::Recreate(item.token.clone())));
}
if view.make_video && item.kind == MediaKind::Image {
buttons.push((Icon::Video, "Make Video", FeedAction::MakeVideo(item.token.clone())));
}
buttons.push((Icon::Link, "Share", FeedAction::Share(item.token.clone())));
buttons.push((Icon::Download, "Download", FeedAction::Download(item.token.clone())));
let pill = Rect::from_min_size(pos2(footer.right() - 8.0 - 8.0 - 28.0 * buttons.len() as f32, chip_y - 18.0), vec2(8.0 + 28.0 * buttons.len() as f32, 36.0));
ui.painter().rect_filled(pill, 0.0, Color32::from_black_alpha(153));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink(4.0)).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing.x = 0.0;
for (icon, tip, action) in buttons {
if widgets::overlay_icon(&mut child, icon, tip).clicked() {
actions.push(action);
action_clicked = true;
}
}
}
if resp.clicked() && !action_clicked {
actions.push(if view.selecting { FeedAction::ToggleSelect(item.token.clone()) } else { FeedAction::Open(item.token.clone()) });
}
}
fn pending_row(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn ModelNames) {
let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB));
ui.painter().rect_filled(thumb, theme::RADIUS, Color32::from_white_alpha(8));
shimmer(ui, thumb);
icons::paint_spinner(ui, Rect::from_center_size(thumb.center(), vec2(22.0, 22.0)), theme::fade(Color32::WHITE, 0.25));
let (progress, label) = pending_status(job, now);
let x = thumb.right() + 16.0;
let w = rect.right() - x;
let prompt = if job.prompt.is_empty() { "Generating\u{2026}" } else { job.prompt.as_str() };
let galley = clamp_lines(ui, prompt, 14.0, theme::INK, w, 2);
let gh = galley.size().y;
ui.painter().galley(pos2(x, thumb.top()), galley, theme::INK);
let meta_y = thumb.top() + gh + 14.0;
let mx = model_chip(ui, pos2(x, meta_y), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::MUTED);
if job.batch_count > 1 {
ui.painter().text(pos2(mx + 8.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {} {}", job.batch_count, job.kind.plural()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED);
}
let bar = Rect::from_min_size(pos2(x, meta_y + 16.0), vec2(w.min(320.0), 4.0));
widgets::progress_bar(ui.painter(), bar, progress as f32 / 100.0);
ui.painter().text(pos2(bar.right() + 10.0, bar.center().y), Align2::LEFT_CENTER, format!("{progress}% \u{b7} {label}"), FontId::new(11.0, FontFamily::Monospace), theme::FAINT);
row_divider(ui, rect);
}
fn failed_row(ui: &mut Ui, id: Id, rect: Rect, job: &FailedJob, names: &dyn ModelNames, actions: &mut Vec<FeedAction>) {
let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB));
ui.painter().rect_filled(thumb, theme::RADIUS, Color32::from_rgba_unmultiplied(239, 68, 68, 26));
icons::paint(ui.painter(), Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), Icon::AlertCircle, theme::BAD);
let x = thumb.right() + 16.0;
let w = rect.right() - x - 100.0;
let galley = clamp_lines(ui, &job.prompt, 14.0, theme::INK, w, 2);
let gh = galley.size().y;
ui.painter().galley(pos2(x, thumb.top()), galley, theme::INK);
ui.painter().text(pos2(x, thumb.top() + gh + 14.0), Align2::LEFT_CENTER, &job.reason, FontId::new(12.0, theme::medium()), theme::BAD);
if let Some(msg) = job.message.as_deref().filter(|m| *m != job.reason) {
let g = clamp_lines(ui, msg, 11.0, theme::fade(theme::BAD, 0.6), w, 2);
ui.painter().galley(pos2(x, thumb.top() + gh + 24.0), g, Color32::WHITE);
}
model_chip(ui, pos2(x, thumb.bottom() - 8.0), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::FAINT);
let button = Rect::from_min_size(pos2(rect.right() - 92.0, thumb.top()), vec2(92.0, 28.0));
let resp = ui.interact(button, id.with(("dismiss-row", &job.job_token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
ui.painter().rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH });
ui.painter().text(button.center(), Align2::CENTER_CENTER, "Dismiss", FontId::new(12.0, FontFamily::Proportional), theme::MUTED);
if resp.clicked() {
actions.push(FeedAction::DismissFailed(job.job_token.clone()));
}
row_divider(ui, rect);
}
#[allow(clippy::too_many_arguments)]
fn item_row(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cache: &mut MediaCache, names: &dyn ModelNames, now: i64, actions: &mut Vec<FeedAction>) {
let resp = ui.interact(rect, view.id.with(("row", &item.token)), Sense::click());
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(rect.expand2(vec2(8.0, 0.0)), 0.0, Color32::from_white_alpha(6));
}
let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB));
ui.painter().rect_filled(thumb, theme::RADIUS, theme::fade(theme::CONTROLS, 0.4));
if let Some(Lookup::Ready(t)) = item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
egui::Image::new(&t).uv(crate::prompt_box::deck::cover_uv(t.size_vec2())).corner_radius(theme::RADIUS).paint_at(ui, thumb);
}
if view.selected.contains(&item.token) {
ui.painter().rect_stroke(thumb, theme::RADIUS, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside);
}
let x = thumb.right() + 16.0;
let w = rect.right() - x - 140.0;
let prompt = item.prompt_token.as_deref().and_then(|t| view.prompts.get(t));
match prompt {
Some(text) => {
let g = clamp_lines(ui, text, 14.0, theme::INK, w, 3);
ui.painter().galley(pos2(x, thumb.top()), g, theme::INK);
},
None if item.prompt_token.is_some() => {
for (i, frac) in [1.0, 0.7].iter().enumerate() {
ui.painter().rect_filled(Rect::from_min_size(pos2(x, thumb.top() + 4.0 + i as f32 * 20.0), vec2(w * frac, 12.0)), CornerRadius::same(2), theme::WASH);
}
},
None => {
ui.painter().text(pos2(x, thumb.top() + 8.0), Align2::LEFT_CENTER, format!("{} Generation", item.kind.label()), FontId::new(14.0, FontFamily::Proportional), theme::MUTED);
},
}
let meta_y = thumb.bottom() - 10.0;
let mut mx = x;
if let Some(model) = item.model_id.as_deref() {
mx = model_chip(ui, pos2(x, meta_y), model, names, 12.0, theme::MUTED);
ui.painter().text(pos2(mx + 6.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {}", item.kind.label()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED);
}
let _ = mx;
ui.painter().text(pos2(rect.right(), thumb.top() + 8.0), Align2::RIGHT_CENTER, time_ago(item.created_at, now), FontId::new(11.0, FontFamily::Proportional), theme::FAINT);
let mut action_clicked = false;
if hovered && !view.selecting {
let mut buttons: Vec<(Icon, &str, FeedAction)> = Vec::new();
if let Some(text) = prompt {
buttons.push((Icon::Copy, "Copy prompt", FeedAction::CopyPrompt(text.clone())));
}
if item.prompt_token.is_some() {
buttons.push((Icon::RotateCw, "Recreate", FeedAction::Recreate(item.token.clone())));
}
if view.make_video && item.kind == MediaKind::Image {
buttons.push((Icon::Video, "Make Video", FeedAction::MakeVideo(item.token.clone())));
}
buttons.push((Icon::Link, "Share", FeedAction::Share(item.token.clone())));
buttons.push((Icon::Download, "Download", FeedAction::Download(item.token.clone())));
let bar = Rect::from_min_size(pos2(rect.right() - 28.0 * buttons.len() as f32, thumb.bottom() - 28.0), vec2(28.0 * buttons.len() as f32, 28.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing.x = 0.0;
for (icon, tip, action) in buttons {
if widgets::overlay_icon(&mut child, icon, tip).clicked() {
actions.push(action);
action_clicked = true;
}
}
}
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() && !action_clicked {
actions.push(if view.selecting { FeedAction::ToggleSelect(item.token.clone()) } else { FeedAction::Open(item.token.clone()) });
}
row_divider(ui, rect);
}
fn row_divider(ui: &Ui, rect: Rect) {
ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, Stroke::new(1.0, Color32::from_white_alpha(10)));
}
/// A small dark chip: an icon (or a creator logo) and a label. Returns its right edge.
fn chip(ui: &Ui, left_center: egui::Pos2, icon: Option<Icon>, creator: Option<&str>, label: &str) -> f32 {
let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::new(12.0, theme::medium()), theme::fade(Color32::WHITE, 0.9));
let lead = if icon.is_some() || creator.is_some() { 16.0 } else { 0.0 };
let rect = Rect::from_min_size(pos2(left_center.x, left_center.y - 12.0), vec2(20.0 + lead + galley.size().x, 24.0));
let p = ui.painter();
p.rect_filled(rect, 0.0, Color32::from_black_alpha(153));
let icon_rect = Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(11.0, 11.0));
if let Some(icon) = icon {
icons::paint(p, icon_rect, icon, theme::fade(Color32::WHITE, 0.9));
} else if let Some(creator) = creator {
let tex = creator_icons::texture(ui.ctx(), creator);
egui::Image::new(&tex).tint(theme::fade(Color32::WHITE, 0.9)).paint_at(ui, icon_rect.expand(0.5));
}
p.galley(pos2(rect.left() + 10.0 + lead, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
rect.right()
}
/// A creator logo followed by the model's name. Returns the right edge.
fn model_chip(ui: &Ui, left_center: egui::Pos2, model_id: &str, names: &dyn ModelNames, size: f32, color: Color32) -> f32 {
if model_id.is_empty() {
return left_center.x;
}
let icon_rect = Rect::from_center_size(pos2(left_center.x + size / 2.0 + 1.0, left_center.y), vec2(size + 2.0, size + 2.0));
let tex = creator_icons::texture(ui.ctx(), &names.creator(model_id));
egui::Image::new(&tex).tint(color).paint_at(ui, icon_rect);
let r = ui.painter().text(pos2(icon_rect.right() + 6.0, left_center.y), Align2::LEFT_CENTER, names.display_name(model_id), FontId::new(size, theme::medium()), color);
r.right()
}
/// Darkens the bottom of `rect` (`bg-gradient-to-t from-black/70`), returning the faded band.
fn bottom_gradient(ui: &Ui, rect: Rect, height: f32, alpha: u8) -> Rect {
let band = Rect::from_min_max(pos2(rect.left(), (rect.bottom() - height).max(rect.top())), rect.max);
let mut mesh = Mesh::default();
let (top, bottom) = (Color32::TRANSPARENT, Color32::from_black_alpha(alpha));
mesh.colored_vertex(band.left_top(), top);
mesh.colored_vertex(band.right_top(), top);
mesh.colored_vertex(band.right_bottom(), bottom);
mesh.colored_vertex(band.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
ui.painter().add(mesh);
band
}
/// The sweeping highlight over loading tiles (`.animate-shimmer`).
fn shimmer(ui: &Ui, rect: Rect) {
let t = (ui.input(|i| i.time) / 1.5).fract() as f32;
let x = rect.left() - rect.width() + t * rect.width() * 3.0;
let band = Rect::from_min_max(pos2(x, rect.top()), pos2(x + rect.width(), rect.bottom())).intersect(rect);
if band.width() > 0.0 {
let mut mesh = Mesh::default();
let (edge, mid) = (Color32::TRANSPARENT, Color32::from_white_alpha(20));
let cx = (x + rect.width() / 2.0).clamp(band.left(), band.right());
for (px, c) in [(band.left(), edge), (cx, mid), (band.right(), edge)] {
mesh.colored_vertex(pos2(px, band.top()), c);
mesh.colored_vertex(pos2(px, band.bottom()), c);
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(1, 2, 3);
mesh.add_triangle(2, 3, 4);
mesh.add_triangle(3, 4, 5);
ui.painter().add(mesh);
}
ui.ctx().request_repaint();
}
/// Lays out `text` wrapped to `width`, keeping at most `max_lines` lines (with an ellipsis).
fn clamp_lines(ui: &Ui, text: &str, size: f32, color: Color32, width: f32, max_lines: usize) -> std::sync::Arc<egui::Galley> {
let mut job = egui::text::LayoutJob::simple(text.to_owned(), FontId::new(size, FontFamily::Proportional), color, width);
job.wrap.max_rows = max_lines;
job.wrap.break_anywhere = false;
ui.painter().layout_job(job)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
format!("{}\u{2026}", s.chars().take(max - 1).collect::<String>())
}
}
/// The UV rect that crops a texture of `size` to fill `target` (`object-cover`).
fn cover_uv_for(size: egui::Vec2, target: egui::Vec2) -> Rect {
if size.x <= 0.0 || size.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
}
let (src, dst) = (size.y / size.x, target.y / target.x);
if src > dst {
let f = dst / src;
Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0))
} else {
let f = src / dst;
Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0))
}
}

View File

@ -0,0 +1,3 @@
pub mod grid;
pub mod store;
pub mod types;

View File

@ -0,0 +1,326 @@
//! One create page's feed (`useDesktopGenerationFeed` + `useGalleryData`): jobs in flight and
//! failures from the session's job list, results as they land, and the library underneath.
use std::collections::{HashMap, HashSet};
use crate::backend::wire::{Job, JobPhase, MediaFile, unix_secs};
use crate::backend::EnqueueMeta;
use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob};
use crate::models::Catalog;
/// Failures older than this are history, not news.
const FAILURE_WINDOW_SECS: i64 = 24 * 60 * 60;
/// Thumbnails are requested at this width (`getMediaThumbnail(@512)`).
pub const THUMBNAIL_WIDTH: u32 = 512;
/// Something the store wants the app to do or say after an update.
#[derive(Debug, PartialEq)]
pub enum FeedNotice {
Completed,
Failed(String),
/// Expand a finished batch job into all of its files.
LoadBatch {
job_token: String,
batch_token: String,
},
}
#[derive(Default)]
pub struct FeedStore {
pub pending: Vec<PendingJob>,
pub failed: Vec<FailedJob>,
pub items: Vec<FeedItem>,
pub has_more: bool,
pub loading: bool,
pub selecting: bool,
pub selected: HashSet<String>,
pub last_viewed: Option<String>,
next_page: u32,
library_started: bool,
/// Jobs seen finished (so only new ones announce themselves).
seen_complete: HashSet<String>,
/// Jobs seen running in this session (so their outcome gets a toast).
seen_running: HashSet<String>,
first_poll_done: bool,
dismissed: HashSet<String>,
meta: HashMap<String, EnqueueMeta>,
}
impl FeedStore {
pub fn has_content(&self) -> bool {
!self.pending.is_empty() || !self.failed.is_empty() || !self.items.is_empty()
}
/// Shows the jobs a generate call just started, before the next poll catches up.
pub fn add_enqueued(&mut self, kind: MediaKind, job_tokens: &[String], meta: &EnqueueMeta, catalog: &Catalog) {
let now = chrono::Utc::now().timestamp();
let expected = catalog.find(&meta.model_id).map_or(30.0, |m| m.expected_secs);
for token in job_tokens {
self.meta.insert(token.clone(), meta.clone());
self.seen_running.insert(token.clone());
if !self.pending.iter().any(|p| &p.job_token == token) {
self.pending.insert(0, PendingJob { job_token: token.clone(), kind, prompt: meta.prompt.clone(), model_id: Some(meta.model_id.clone()), created_at: now, server_progress: None, expected_secs: expected, batch_count: meta.batch_count });
}
}
}
/// Applies a poll of the session's jobs, keeping only `kind`'s.
pub fn apply_jobs(&mut self, kind: MediaKind, jobs: &[Job], catalog: &Catalog) -> Vec<FeedNotice> {
let now = chrono::Utc::now().timestamp();
let mut notices = Vec::new();
let mut pending = Vec::new();
let mut failed = Vec::new();
let relevant: Vec<&Job> = jobs.iter().filter(|j| job_kind(j, catalog) == Some(kind) && !self.dismissed.contains(&j.job_token)).collect();
for job in relevant {
let meta = self.meta.get(&job.job_token).cloned();
let meta = meta.as_ref();
let model_id = meta.map(|m| m.model_id.clone()).or_else(|| job.request.maybe_model_type.clone());
let prompt = meta.map(|m| m.prompt.clone()).or_else(|| job.request.maybe_raw_inference_text.clone()).unwrap_or_default();
match job.status.phase() {
JobPhase::Running => {
self.seen_running.insert(job.job_token.clone());
let expected = model_id.as_deref().and_then(|id| catalog.find(id)).map_or(if kind == MediaKind::Video { 900.0 } else { 30.0 }, |m| m.expected_secs);
pending.push(PendingJob { job_token: job.job_token.clone(), kind, prompt, model_id, created_at: unix_secs(&job.created_at), server_progress: Some(job.status.progress_percentage), expected_secs: expected, batch_count: meta.map_or(1, |m| m.batch_count) });
},
JobPhase::Failed => {
let updated = unix_secs(&job.updated_at);
if self.seen_running.remove(&job.job_token) {
notices.push(FeedNotice::Failed(job.status.maybe_failure_message.clone().unwrap_or_else(|| job.status.failure_reason())));
}
if now - updated <= FAILURE_WINDOW_SECS {
failed.push(FailedJob { job_token: job.job_token.clone(), prompt, model_id, updated_at: updated, reason: job.status.failure_reason(), message: job.status.maybe_failure_message.clone().filter(|_| job.status.maybe_failure_category_updated.as_deref() != Some("unknown")), ref_image: meta.and_then(|m| m.ref_image.clone()) });
}
},
JobPhase::Succeeded => {
if !self.seen_complete.insert(job.job_token.clone()) || !self.first_poll_done {
continue;
}
if self.seen_running.remove(&job.job_token) {
notices.push(FeedNotice::Completed);
}
let Some(result) = &job.maybe_result else {
continue;
};
// The finished card takes the pending card's place: date it by the job.
let created_at = unix_secs(&job.created_at);
self.push_item(FeedItem { token: result.entity_token.clone(), kind, thumbnail: result.media_links.thumbnail(THUMBNAIL_WIDTH), full_url: result.media_links.cdn_url.clone(), created_at, model_id: model_id.clone(), prompt_token: job.request.maybe_prompt_token.clone(), batch_token: result.maybe_batch_token.clone(), duration_secs: None });
if let Some(batch) = &result.maybe_batch_token {
notices.push(FeedNotice::LoadBatch { job_token: job.job_token.clone(), batch_token: batch.clone() });
}
},
}
}
// Jobs started a moment ago may not be in the list yet: keep them.
for p in self.pending.drain(..) {
let listed = jobs.iter().any(|j| j.job_token == p.job_token);
if !listed && now - p.created_at < 60 && !pending.iter().any(|q: &PendingJob| q.job_token == p.job_token) {
pending.push(p);
}
}
// Everything finished before the first poll comes from the library instead.
self.first_poll_done = true;
pending.sort_by_key(|p| std::cmp::Reverse(p.created_at));
failed.sort_by_key(|f| std::cmp::Reverse(f.updated_at));
self.pending = pending;
self.failed = failed;
notices
}
/// Adds every file of a finished batch, dated like the batch's first card.
pub fn apply_batch(&mut self, kind: MediaKind, files: &[MediaFile]) {
for file in files {
let mut item = feed_item(kind, file);
if let Some(first) = self.items.iter().find(|i| i.batch_token.is_some() && i.batch_token == file.maybe_batch_token) {
item.created_at = first.created_at;
}
self.push_item(item);
}
}
pub fn library_started(&self) -> bool {
self.library_started
}
/// The next library page to fetch, if one is due.
pub fn next_library_page(&mut self) -> Option<u32> {
if self.loading || (self.library_started && !self.has_more) {
return None;
}
self.loading = true;
self.library_started = true;
Some(self.next_page)
}
pub fn apply_library_page(&mut self, kind: MediaKind, page: u32, files: &[MediaFile], total_pages: u32) {
self.loading = false;
if page != self.next_page {
return;
}
for file in files {
self.push_item(feed_item(kind, file));
}
self.next_page = page + 1;
self.has_more = self.next_page < total_pages;
}
pub fn library_failed(&mut self) {
self.loading = false;
}
/// Starts the library over (after signing in or out).
pub fn reset(&mut self) {
*self = Self { dismissed: std::mem::take(&mut self.dismissed), ..Self::default() };
}
pub fn dismiss(&mut self, job_token: &str) {
self.dismissed.insert(job_token.to_owned());
self.failed.retain(|f| f.job_token != job_token);
}
pub fn remove_item(&mut self, token: &str) {
self.items.retain(|i| i.token != token);
self.selected.remove(token);
}
pub fn find(&self, token: &str) -> Option<&FeedItem> {
self.items.iter().find(|i| i.token == token)
}
/// Finished items newest first (the lightbox's prev/next order).
pub fn ordered_tokens(&self) -> Vec<String> {
let mut items: Vec<&FeedItem> = self.items.iter().collect();
items.sort_by_key(|i| std::cmp::Reverse(i.created_at));
items.into_iter().map(|i| i.token.clone()).collect()
}
fn push_item(&mut self, item: FeedItem) {
if !item.token.is_empty() && !self.items.iter().any(|i| i.token == item.token) {
self.items.push(item);
}
}
}
/// Builds a feed item from a library or batch file.
pub fn feed_item(kind: MediaKind, file: &MediaFile) -> FeedItem {
FeedItem {
token: file.token.clone(),
kind: match file.media_class.as_deref() {
Some("video") => MediaKind::Video,
Some("image") => MediaKind::Image,
_ => kind,
},
thumbnail: file.media_links.thumbnail(THUMBNAIL_WIDTH),
full_url: file.media_links.cdn_url.clone(),
created_at: unix_secs(&file.created_at),
model_id: file.maybe_model_type.clone().or_else(|| file.maybe_origin_model_type.clone()),
prompt_token: file.maybe_prompt_token.clone(),
batch_token: file.maybe_batch_token.clone(),
duration_secs: file.maybe_duration_millis.map(|ms| ms as f32 / 1000.0),
}
}
/// Whether a job made an image or a video: by its model, else by its category.
fn job_kind(job: &Job, catalog: &Catalog) -> Option<MediaKind> {
if let Some(model) = job.request.maybe_model_type.as_deref() {
if catalog.image.iter().any(|m| m.id == model) {
return Some(MediaKind::Image);
}
if catalog.video.iter().any(|m| m.id == model) {
return Some(MediaKind::Video);
}
}
let category = job.request.inference_category.as_str();
if category.contains("video") {
Some(MediaKind::Video)
} else if category.contains("image") {
Some(MediaKind::Image)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ModelInfo;
fn catalog() -> Catalog {
let mut c = Catalog::default();
c.set_image(vec![ModelInfo { id: "nano_banana_pro".into(), name: "Nano Banana Pro".into(), prompt_supported: true, ..Default::default() }]);
c.set_video(vec![ModelInfo { id: "seedance_2p0".into(), name: "Seedance 2.0".into(), ..Default::default() }]);
c
}
fn job(token: &str, model: &str, status: &str, created: &str) -> Job {
serde_json::from_value(serde_json::json!({
"job_token": token,
"request": { "inference_category": "image_generation", "maybe_model_type": model, "maybe_raw_inference_text": "a red knight" },
"status": { "status": status, "progress_percentage": 40, "maybe_failure_message": "Too spicy", "maybe_failure_category_updated": "rule_bans_user_text_prompt" },
"maybe_result": { "entity_token": format!("m_{token}"), "media_links": { "cdn_url": "https://cdn/x.png", "maybe_thumbnail_template": "https://cdn/w={WIDTH}/x.png" } },
"created_at": created,
"updated_at": created,
}))
.expect("job json")
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
#[test]
fn first_poll_leaves_old_results_to_the_library() {
let (c, mut store) = (catalog(), FeedStore::default());
let notices = store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "complete_success", &now_rfc3339())], &c);
assert!(notices.is_empty());
assert!(store.items.is_empty());
}
#[test]
fn running_jobs_become_pending_then_announce_their_result() {
let (c, mut store) = (catalog(), FeedStore::default());
let t = now_rfc3339();
store.apply_jobs(MediaKind::Image, &[], &c);
store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "started", &t)], &c);
assert_eq!(store.pending.len(), 1);
assert_eq!(store.pending[0].prompt, "a red knight");
assert_eq!(store.pending[0].server_progress, Some(40));
let notices = store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "complete_success", &t)], &c);
assert_eq!(notices, vec![FeedNotice::Completed]);
assert!(store.pending.is_empty());
assert_eq!(store.items[0].token, "m_j1");
assert_eq!(store.items[0].thumbnail.as_deref(), Some("https://cdn/w=512/x.png"));
}
#[test]
fn failures_show_with_their_reason_and_can_be_dismissed() {
let (c, mut store) = (catalog(), FeedStore::default());
let t = now_rfc3339();
store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "pending", &t)], &c);
let notices = store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "complete_failure", &t)], &c);
assert_eq!(notices, vec![FeedNotice::Failed("Too spicy".into())]);
assert_eq!(store.failed[0].reason, "Text prompt violates content policy");
store.dismiss("j2");
store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "complete_failure", &t)], &c);
assert!(store.failed.is_empty());
}
#[test]
fn jobs_go_to_the_feed_of_their_model() {
let (c, mut store) = (catalog(), FeedStore::default());
store.apply_jobs(MediaKind::Video, &[job("j3", "nano_banana_pro", "started", &now_rfc3339())], &c);
assert!(store.pending.is_empty());
store.apply_jobs(MediaKind::Video, &[job("j4", "seedance_2p0", "started", &now_rfc3339())], &c);
assert_eq!(store.pending.len(), 1);
}
#[test]
fn just_enqueued_jobs_survive_a_poll_that_predates_them() {
let (c, mut store) = (catalog(), FeedStore::default());
let meta = EnqueueMeta { prompt: "p".into(), model_id: "nano_banana_pro".into(), batch_count: 2, ref_image: None };
store.add_enqueued(MediaKind::Image, &["fresh".into()], &meta, &c);
store.apply_jobs(MediaKind::Image, &[], &c);
assert_eq!(store.pending.len(), 1);
assert_eq!(store.pending[0].batch_count, 2);
}
}

View File

@ -0,0 +1,107 @@
//! What the generation feed shows: jobs still running, jobs that failed, and finished media.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MediaKind {
Image,
Video,
}
impl MediaKind {
pub fn label(self) -> &'static str {
match self {
MediaKind::Image => "Image",
MediaKind::Video => "Video",
}
}
pub fn plural(self) -> &'static str {
match self {
MediaKind::Image => "images",
MediaKind::Video => "videos",
}
}
}
/// A finished image or video in the user's library.
#[derive(Clone, Debug)]
pub struct FeedItem {
/// The media file token.
pub token: String,
pub kind: MediaKind,
/// A thumbnail URL (512 px wide where the CDN can resize).
pub thumbnail: Option<String>,
/// The original file.
pub full_url: String,
/// Unix seconds.
pub created_at: i64,
/// The model that made it (`maybe_model_type`).
pub model_id: Option<String>,
pub prompt_token: Option<String>,
pub batch_token: Option<String>,
pub duration_secs: Option<f32>,
}
/// A generation the server is still working on.
#[derive(Clone, Debug)]
pub struct PendingJob {
pub job_token: String,
pub kind: MediaKind,
pub prompt: String,
pub model_id: Option<String>,
/// Unix seconds when it was enqueued.
pub created_at: i64,
/// The server's progress (0–100), when it reports any.
pub server_progress: Option<u8>,
/// How long this model usually takes, for the estimated progress bar.
pub expected_secs: f32,
pub batch_count: u32,
}
/// A generation that failed or was cancelled.
#[derive(Clone, Debug)]
pub struct FailedJob {
pub job_token: String,
pub prompt: String,
pub model_id: Option<String>,
pub updated_at: i64,
pub reason: String,
pub message: Option<String>,
/// The first reference image, shown faintly behind the error.
pub ref_image: Option<String>,
}
/// The progress and time-left labels for a pending job (`derivePendingStatus`).
pub fn pending_status(job: &PendingJob, now: i64) -> (u8, String) {
let elapsed = (now - job.created_at).max(0) as f32;
let estimated = ((elapsed / job.expected_secs.max(1.0)) * 100.0).min(95.0) as u8;
let progress = job.server_progress.filter(|p| *p > 0).unwrap_or(estimated).min(99);
if progress >= 95 {
return (progress, "Almost done...".to_owned());
}
let left = (job.expected_secs - elapsed).max(1.0) as i64;
let (h, m, s) = (left / 3600, (left % 3600) / 60, left % 60);
let label = if h > 0 && m > 0 {
format!("~{h}h {m}m")
} else if h > 0 {
format!("~{h}h")
} else if m > 0 {
format!("~{m}m")
} else {
format!("~{s}s")
};
(progress, label)
}
/// "just now", "5 minutes ago", … for list rows.
pub fn time_ago(then: i64, now: i64) -> String {
let d = (now - then).max(0);
let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" });
match d {
0..=59 => "just now".to_owned(),
60..=3599 => plural(d / 60, "minute"),
3600..=86_399 => plural(d / 3600, "hour"),
86_400..=2_591_999 => plural(d / 86_400, "day"),
2_592_000..=31_535_999 => plural(d / 2_592_000, "month"),
_ => plural(d / 31_536_000, "year"),
}
}

View File

@ -0,0 +1,35 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
//! ArtCraft, natively: an egui desktop app over the ArtCraft API (no Tauri, no webview).
mod app;
mod backend;
mod feed;
mod models;
mod overlays;
mod pages;
mod prompt_box;
mod shell;
mod theme;
mod ui;
const APP_ID: &str = "ai.artcraft.native";
fn main() -> eframe::Result {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let mut viewport = egui::ViewportBuilder::default().with_title("ArtCraft").with_app_id(APP_ID).with_inner_size([1440.0, 900.0]).with_min_inner_size([960.0, 640.0]);
if let Some(icon) = app_icon() {
viewport = viewport.with_icon(icon);
}
// The app draws its own title bar; macOS keeps its traffic lights over it.
viewport = if cfg!(target_os = "macos") { viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false) } else { viewport.with_decorations(false) };
let options = eframe::NativeOptions { viewport, persist_window: true, ..Default::default() };
eframe::run_native("ArtCraft", options, Box::new(|cc| Ok(Box::new(app::ArtcraftApp::new(cc)))))
}
/// The window and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
const ICON: &[u8] = include_bytes!("../../crates/desktop/artcraft/icons/icon.png");
let rgba = image::load_from_memory(ICON).ok()?.into_rgba8();
Some(egui::IconData { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() })
}

View File

@ -0,0 +1,629 @@
//! The model catalog: capabilities come from the server's OmniGen listing (so new models and
//! options show up without an app update); names, descriptions, families and typical durations
//! are filled in here when the server doesn't say.
use crate::feed::grid::ModelNames;
use crate::feed::types::MediaKind;
/// Default models (`defaultModelForPage.ts`).
pub const DEFAULT_IMAGE_MODEL: &str = "nano_banana_pro";
pub const DEFAULT_VIDEO_MODEL: &str = "seedance_2p0";
/// The fallback prompt limit (`maxPromptLength ?? 1000`).
pub const DEFAULT_PROMPT_MAX: usize = 1000;
/// The model pickers group by family once they list this many models.
const GROUP_THRESHOLD: usize = 8;
/// One generation model and what it accepts.
#[derive(Clone, Debug, Default)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub description: String,
/// Snake-case maker (`google`, `bytedance`, …), for the logo.
pub creator: String,
pub family: String,
pub disabled: bool,
pub prompt_supported: bool,
/// `None` = unlimited.
pub prompt_max: Option<usize>,
pub aspect_ratios: Vec<String>,
pub aspect_default: Option<String>,
pub resolutions: Vec<String>,
pub resolution_default: Option<String>,
pub qualities: Vec<String>,
pub quality_default: Option<String>,
pub batch_sizes: Vec<u16>,
pub batch_default: u16,
pub image_refs_max: usize,
// Video only.
pub text_to_video: bool,
pub start_frame: bool,
pub start_frame_required: bool,
pub end_frame: bool,
pub sound_toggle: bool,
pub video_refs_max: usize,
pub video_refs_max_secs: Option<u16>,
pub audio_refs_max: usize,
pub audio_refs_max_secs: Option<u16>,
pub duration_options: Vec<u16>,
pub duration_min: Option<u16>,
pub duration_max: Option<u16>,
pub duration_max_with_refs: Option<u16>,
pub duration_default: Option<u16>,
pub bitrates: Vec<String>,
pub bitrate_default: Option<String>,
pub output_formats: Vec<String>,
pub output_format_default: Option<String>,
/// How long a generation usually takes (drives the estimated progress bar).
pub expected_secs: f32,
}
impl ModelInfo {
/// Batch sizes the count picker offers (`predefined` or `1..=max`).
pub fn batch_options(&self) -> Vec<u16> {
if self.batch_sizes.is_empty() {
vec![1]
} else {
self.batch_sizes.clone()
}
}
/// `count` if this model allows it, else its default.
pub fn valid_batch(&self, count: u16) -> u16 {
let options = self.batch_options();
if options.contains(&count) {
count
} else if options.contains(&self.batch_default) {
self.batch_default
} else {
options[0]
}
}
/// `current` if supported, else the default, else the first option (`resolveQuality` & co.).
pub fn resolve(current: Option<&str>, options: &[String], default: Option<&String>) -> Option<String> {
match current {
Some(c) if options.iter().any(|o| o == c) => Some(c.to_owned()),
_ => default.filter(|d| options.is_empty() || options.contains(d)).cloned().or_else(|| options.first().cloned()),
}
}
pub fn supports_reference_mode(&self) -> bool {
self.image_refs_max > 0 || self.video_refs_max > 0 || self.audio_refs_max > 0
}
/// Whether the duration picker shows (a range or several options).
pub fn duration_range(&self, with_refs: bool) -> Option<(u16, u16)> {
let max = if with_refs { self.duration_max_with_refs.or(self.duration_max) } else { self.duration_max };
match (self.duration_min, max) {
(Some(min), Some(max)) if max > min => Some((min, max)),
_ => {
let (lo, hi) = (self.duration_options.iter().min()?, self.duration_options.iter().max()?);
(hi > lo).then_some((*lo, *hi))
},
}
}
/// Snaps `current` into the model's duration range (preferring the higher neighbour), or its
/// default (`resolveVideoDuration`).
pub fn resolve_duration(&self, current: Option<u16>, with_refs: bool) -> Option<u16> {
let default = self.duration_default.or_else(|| self.duration_options.first().copied()).or(self.duration_min);
let current = current.or(default)?;
if !self.duration_options.is_empty() {
let cap = if with_refs { self.duration_max_with_refs } else { None };
let mut options: Vec<u16> = self.duration_options.iter().copied().filter(|o| cap.is_none_or(|c| *o <= c)).collect();
options.sort_unstable();
return options.iter().copied().find(|&o| o >= current).or(options.last().copied()).or(default);
}
match self.duration_range(with_refs) {
Some((min, max)) => Some(current.clamp(min, max)),
None => default,
}
}
}
/// Builds a model from the server's image listing.
pub fn image_model(d: &artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::OmniGenImageModelDetails) -> ModelInfo {
let id = String::from(d.model.clone());
let mut m = base(&id, MediaKind::Image, d.full_name.as_deref(), d.model_creator.clone().map(String::from));
m.disabled = d.is_disabled.unwrap_or(false);
m.prompt_supported = d.text_prompt_supported.unwrap_or(true);
m.prompt_max = d.text_prompt_max_length.map(usize::from).or_else(|| default_prompt_max(&id));
m.aspect_ratios = strings(&d.aspect_ratio_options);
m.aspect_default = d.aspect_ratio_default.clone().map(String::from);
m.resolutions = strings(&d.resolution_options);
m.resolution_default = d.resolution_default.clone().map(String::from);
m.qualities = strings(&d.quality_options);
m.quality_default = d.default_quality.clone().map(String::from);
m.batch_sizes = batch_sizes(d.batch_size_options.as_deref(), d.batch_size_min, d.batch_size_max);
m.batch_default = d.batch_size_default.unwrap_or(1);
m.image_refs_max = if d.image_refs_supported.unwrap_or(false) { d.image_refs_max.map_or(1, usize::from) } else { 0 };
m
}
/// Builds a model from the server's video listing.
pub fn video_model(d: &artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::OmniGenVideoModelDetails) -> ModelInfo {
let id = String::from(d.model.clone());
let mut m = base(&id, MediaKind::Video, d.full_name.as_deref(), d.model_creator.clone().map(String::from));
if let Some(short) = d.extra_info_short.as_deref().filter(|s| !s.is_empty()) {
m.description = short.to_owned();
}
m.disabled = d.is_disabled.unwrap_or(false);
m.prompt_supported = d.text_prompt_supported.unwrap_or(true);
m.prompt_max = d.text_prompt_max_length.map(usize::from).or_else(|| default_prompt_max(&id));
m.text_to_video = d.text_to_video_supported.unwrap_or(true);
m.start_frame = d.starting_keyframe_supported.unwrap_or(false);
m.start_frame_required = d.starting_keyframe_required.unwrap_or(false);
m.end_frame = d.ending_keyframe_supported.unwrap_or(false);
m.sound_toggle = d.show_generate_with_sound_toggle.unwrap_or(false);
let max = |supported: Option<bool>, max: Option<u16>| if supported.unwrap_or(false) { max.map_or(1, usize::from) } else { 0 };
m.image_refs_max = max(d.image_references_supported, d.image_references_max);
m.video_refs_max = max(d.video_references_supported, d.video_references_max);
m.video_refs_max_secs = d.video_references_max_total_duration_seconds;
m.audio_refs_max = max(d.audio_references_supported, d.audio_references_max);
m.audio_refs_max_secs = d.audio_references_max_total_duration_seconds;
m.aspect_ratios = strings(&d.aspect_ratio_options);
m.aspect_default = d.aspect_ratio_default.clone().map(String::from);
m.resolutions = strings(&d.resolution_options);
m.resolution_default = d.resolution_default.clone().map(String::from);
m.qualities = strings(&d.quality_options);
m.quality_default = d.default_quality.clone().map(String::from);
m.bitrates = strings(&d.bitrate_options);
m.bitrate_default = d.bitrate_default.clone().map(String::from);
m.output_formats = d.output_format_options.clone().unwrap_or_default();
m.output_format_default = d.output_format_default.clone();
m.duration_options = d.duration_seconds_options.clone().unwrap_or_default();
m.duration_min = d.duration_seconds_min;
m.duration_max = d.duration_seconds_max;
m.duration_max_with_refs = d.duration_seconds_max_with_image_references;
m.duration_default = d.duration_seconds_default;
m.batch_sizes = batch_sizes(d.batch_size_options.as_deref(), d.batch_size_min, d.batch_size_max);
m.batch_default = d.batch_size_default.unwrap_or(1);
m
}
fn strings<T: Clone + Into<String>>(options: &Option<Vec<T>>) -> Vec<String> {
options.as_deref().unwrap_or_default().iter().cloned().map(Into::into).collect()
}
fn batch_sizes(options: Option<&[u16]>, min: Option<u16>, max: Option<u16>) -> Vec<u16> {
match options.filter(|o| !o.is_empty()) {
Some(options) => options.to_vec(),
None => (min.unwrap_or(1).max(1)..=max.unwrap_or(1).max(1)).collect(),
}
}
fn base(id: &str, kind: MediaKind, full_name: Option<&str>, creator: Option<String>) -> ModelInfo {
// Known families use the pickers' curated names ("Flux 1 Dev", not "FLUX.1 [dev]"); models
// the app doesn't know yet use the server's name.
let name = if has_curated_name(id) { display_name(id) } else { full_name.filter(|n| !n.is_empty()).map_or_else(|| display_name(id), str::to_owned) };
// The server reports "artcraft" for models it serves itself; show the actual maker when the id
// says who that is (Nano Banana → Google), like the webapp.
let creator = match creator.filter(|c| !c.is_empty() && c != "unknown") {
Some(c) if c == "artcraft" && creator_for(id) != "artcraft" => creator_for(id).to_owned(),
Some(c) => c,
None => creator_for(id).to_owned(),
};
ModelInfo { id: id.to_owned(), name, description: description(id).to_owned(), creator, family: family(id).to_owned(), prompt_supported: true, text_to_video: true, batch_default: 1, expected_secs: expected_secs(id, kind), ..Default::default() }
}
/// Families whose picker names the app curates (the static model lists' `selectorName`s).
fn has_curated_name(id: &str) -> bool {
const PREFIXES: [&str; 11] = ["flux_", "nano_banana", "gpt_image", "seedream", "seedance", "kling", "veo_", "sora", "happy_horse", "grok_imagine", "qwen"];
PREFIXES.iter().any(|p| id.starts_with(p))
}
/// A readable name for a model id: the irregular ones by table, the rest by rule
/// (`seedance_2p0_fast` → "Seedance 2.0 Fast").
pub fn display_name(id: &str) -> String {
let special = match id {
"nano_banana" | "gemini_25_flash" => "Nano Banana",
"grok_image" => "Grok Image",
"grok_imagine_image" => "Grok",
"grok_imagine_video" => "Grok Video",
"grok_imagine_image_q" => "Grok Imagine Quality",
"gpt_image_1" => "GPT Image 1 (GPT-4o)",
"flux_pro_1" => "Flux Pro (Inpainting)",
"seedance_2p0_bp" => "Seedance 2.0 Plus",
"seedance_2p0_bp_fast" => "Seedance 2.0 Plus Fast",
"seedance_2p0_bp_mini" => "Seedance 2.0 Plus Mini",
"seedance_2p0_bpu" => "Seedance 2.0 Plus Ultra",
"seedance_2p0_bpu_fast" => "Seedance 2.0 Plus Ultra Fast",
"seedance_2p0_bpu_mini" => "Seedance 2.0 Plus Ultra Mini",
"marble_0p1_mini" => "Marble Mini",
"marble_0p1_plus" => "Marble Plus",
"minimax_h3" => "MiniMax H3",
_ => "",
};
if !special.is_empty() {
return special.to_owned();
}
let words: Vec<String> = id
.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let bytes = w.as_bytes();
// "2p0" → "2.0", "1p5" → "1.5"
if bytes[0].is_ascii_digit() && w.contains('p') && w.chars().all(|c| c.is_ascii_digit() || c == 'p') {
return w.replace('p', ".");
}
match w {
"gpt" => "GPT".to_owned(),
"veo" => "Google Veo".to_owned(),
"u" => "Ultra".to_owned(),
"q" => "Quality".to_owned(),
_ => {
let mut c = w.chars();
c.next().map_or_else(String::new, |f| f.to_uppercase().chain(c).collect())
},
}
})
.collect();
words.join(" ")
}
/// A model's prompt limit when the listing doesn't give one (the static lists' `maxPromptLength`;
/// `None` = unlimited, unknown models get the desktop's 1000).
fn default_prompt_max(id: &str) -> Option<usize> {
const LIMITS: [(&str, Option<usize>); 15] = [("nano_banana", None), ("gpt_image", None), ("seedance_2", None), ("seedance_1", Some(3000)), ("grok_imagine_video", Some(4096)), ("grok_video", Some(4096)), ("grok", Some(8000)), ("midjourney", Some(6000)), ("flux", Some(4000)), ("seedream", Some(4000)), ("qwen", Some(800)), ("kling", Some(2500)), ("happy_horse", Some(2500)), ("sora", Some(2000)), ("veo", Some(5000))];
LIMITS.iter().find(|(prefix, _)| id.starts_with(prefix)).map_or(Some(DEFAULT_PROMPT_MAX), |(_, limit)| *limit)
}
/// The image picker's one-liners (`selectorDescription`).
fn description(id: &str) -> &'static str {
match id {
"grok_image" | "grok_imagine_image" => "Fast af",
"midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8" => "Stunning style and quality",
"flux_pro_1p1_ultra" => "Higher quality model",
"flux_pro_1p1" => "High quality model",
"flux_1_dev" => "Fast, but lower quality",
"flux_1_schnell" => "Fastest image gen, but lowest quality",
"nano_banana" | "nano_banana_2" => "Fast instructive editing",
"nano_banana_pro" => "Powerful instructive editing",
"gpt_image_1" => "Slow, but super smart",
"gpt_image_1p5" => "Faster, improved",
"gpt_image_2" => "Smart with great text support",
"gpt_image_2p5_flare" => "Precise edits, keeps subjects intact",
"gpt_image_2p5_sunburst" => "Premium detail, slower",
"seedream_4" | "seedream_4p5" | "seedream_5_lite" => "Fast",
"grok_video" | "grok_imagine_video" => "Fastest video model",
"sora_2" => "Smart video model",
"veo_3" => "Slow, high-quality model",
_ => "",
}
}
/// Typical generation time (the static list's `progressBarTime`).
fn expected_secs(id: &str, kind: MediaKind) -> f32 {
match id {
"grok_image" | "grok_imagine_image" | "flux_pro_1p1" | "flux_1_dev" | "flux_1_schnell" => 10.0,
"grok_video" | "grok_imagine_video" => 50.0,
"nano_banana" | "nano_banana_2" | "nano_banana_pro" => 25.0,
"flux_pro_1p1_ultra" => 35.0,
"midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8" => 45.0,
"gpt_image_1" | "gpt_image_1p5" | "gpt_image_2p5_flare" | "seedream_4" | "seedream_4p5" | "seedream_5_lite" => 60.0,
"gpt_image_2" | "gpt_image_2p5_sunburst" => 120.0,
_ if kind == MediaKind::Video => 900.0,
_ => 30.0,
}
}
/// The maker, by id prefix, when the server doesn't say.
pub fn creator_for(id: &str) -> &'static str {
const PREFIXES: [(&str, &str); 16] = [("flux", "black_forest_labs"), ("nano_banana", "google"), ("gemini", "google"), ("veo", "google"), ("imagen", "google"), ("gpt_image", "openai"), ("sora", "openai"), ("seedream", "bytedance"), ("seedance", "bytedance"), ("seededit", "bytedance"), ("kling", "kling"), ("grok", "grok"), ("midjourney", "midjourney"), ("qwen", "alibaba"), ("happy_horse", "alibaba"), ("minimax", "minimax")];
PREFIXES.iter().find(|(p, _)| id.starts_with(p)).map_or("artcraft", |(_, c)| c)
}
/// The picker family (`getModelFamilyName`).
fn family(id: &str) -> &'static str {
const FAMILIES: [(&str, &str); 22] = [("seedance", "Seedance"), ("kling", "Kling"), ("veo", "Veo"), ("sora", "Sora"), ("happy_horse", "Happy Horse"), ("wan", "Wan"), ("vidu", "Vidu"), ("preview", "Preview"), ("nano_banana", "Nano Banana"), ("gemini_25_flash", "Nano Banana"), ("seedream", "Seedream"), ("seededit", "SeedEdit"), ("gpt_image", "GPT Image"), ("flux", "Flux"), ("midjourney", "Midjourney"), ("grok", "Grok"), ("qwen", "Qwen"), ("recraft", "Recraft"), ("imagen", "Imagen"), ("minimax", "MiniMax"), ("marble", "World Labs"), ("suno", "Suno")];
FAMILIES.iter().find(|(p, _)| id.starts_with(p)).map_or("Other", |(_, f)| f)
}
/// Family order in the picker (`FAMILY_ORDER`): video families, then image families; the rest
/// alphabetical, "Other" last.
fn family_rank(family: &str) -> (usize, String) {
const ORDER: [&str; 17] = ["Seedance", "Kling", "Veo", "Sora", "Happy Horse", "Wan", "Vidu", "Grok", "Nano Banana", "Seedream", "GPT Image", "Flux", "Midjourney", "Qwen", "SeedEdit", "Recraft", "Imagen"];
match ORDER.iter().position(|f| *f == family) {
Some(i) => (i, String::new()),
None if family == "Other" => (usize::MAX, String::new()),
None => (ORDER.len(), family.to_owned()),
}
}
/// A group of models in the picker.
pub struct Family<'a> {
pub name: String,
pub models: Vec<&'a ModelInfo>,
}
/// Groups `models` by family (when there are enough of them), newest variant first.
pub fn grouped(models: &[ModelInfo]) -> Option<Vec<Family<'_>>> {
if models.len() < GROUP_THRESHOLD {
return None;
}
let mut families: Vec<Family<'_>> = Vec::new();
for m in models {
match families.iter_mut().find(|f| f.name == m.family) {
Some(f) => f.models.push(m),
None => families.push(Family { name: m.family.clone(), models: vec![m] }),
}
}
families.sort_by_key(|f| family_rank(&f.name));
for f in &mut families {
f.models.sort_by(|a, b| natural_desc(&a.name, &b.name));
}
Some(families)
}
/// Descending, comparing digit runs as numbers ("Seedream 4.5" before "Seedream 4").
fn natural_desc(a: &str, b: &str) -> std::cmp::Ordering {
fn key(s: &str) -> Vec<(String, f64)> {
s.split(|c: char| c.is_whitespace()).map(|w| (w.to_lowercase(), w.parse::<f64>().unwrap_or(-1.0))).collect()
}
let (ka, kb) = (key(a), key(b));
for (x, y) in ka.iter().zip(&kb) {
let ord = if x.1 >= 0.0 && y.1 >= 0.0 { y.1.total_cmp(&x.1) } else { y.0.cmp(&x.0) };
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
kb.len().cmp(&ka.len()).reverse()
}
/// The image page's list: text-to-image capable, enabled, sorted by name.
fn image_page_models(models: &[ModelInfo]) -> Vec<ModelInfo> {
const HIDDEN: [&str; 2] = ["minimax_h3_turbo", "minimax_h3_ultra"];
let mut list: Vec<ModelInfo> = models.iter().filter(|m| m.prompt_supported && !m.disabled && !HIDDEN.contains(&m.id.as_str())).cloned().collect();
list.sort_by_key(|m| m.name.to_lowercase());
list
}
/// The video page's list: enabled, without SwitchX, sorted by name.
fn video_page_models(models: &[ModelInfo]) -> Vec<ModelInfo> {
let mut list: Vec<ModelInfo> = models.iter().filter(|m| !m.disabled && m.id != "switch_x").cloned().collect();
list.sort_by_key(|m| m.name.to_lowercase());
list
}
/// Both catalogs, for looking up names in the feed, and each create page's picker list.
#[derive(Default)]
pub struct Catalog {
pub image: Vec<ModelInfo>,
pub video: Vec<ModelInfo>,
pub image_page: Vec<ModelInfo>,
pub video_page: Vec<ModelInfo>,
}
impl Catalog {
pub fn set_image(&mut self, models: Vec<ModelInfo>) {
self.image_page = image_page_models(&models);
self.image = models;
}
pub fn set_video(&mut self, models: Vec<ModelInfo>) {
self.video_page = video_page_models(&models);
self.video = models;
}
pub fn find(&self, id: &str) -> Option<&ModelInfo> {
self.image.iter().chain(&self.video).find(|m| m.id == id)
}
}
impl ModelNames for Catalog {
fn display_name(&self, model_id: &str) -> String {
self.find(model_id).map_or_else(|| display_name(model_id), |m| m.name.clone())
}
fn creator(&self, model_id: &str) -> String {
self.find(model_id).map_or_else(|| creator_for(model_id).to_owned(), |m| m.creator.clone())
}
}
/// Aspect ratio labels (`AspectRatioPicker`).
pub fn aspect_label(value: &str) -> String {
match value {
"auto" => "Auto",
"square" => "Square",
"wide_five_by_four" => "5:4 (Wide)",
"wide_four_by_three" => "4:3 (Wide)",
"wide_three_by_two" => "3:2 (Wide)",
"wide_sixteen_by_nine" => "16:9 (Wide)",
"wide_twenty_one_by_nine" => "21:9 (Wide)",
"tall_four_by_five" => "4:5 (Tall)",
"tall_three_by_four" => "3:4 (Tall)",
"tall_two_by_three" => "2:3 (Tall)",
"tall_nine_by_sixteen" => "9:16 (Tall)",
"tall_nine_by_twenty_one" => "9:21 (Tall)",
"auto_2k" => "Auto (2K)",
"auto_3k" => "Auto (3K)",
"auto_4k" => "Auto (4K)",
"square_hd" => "Square (HD)",
"wide" => "Wide",
"tall" => "Tall",
other => other,
}
.to_owned()
}
/// The proportions an aspect ratio's icon draws (`AspectRatioIcon`); `None` for the auto ones.
pub fn aspect_proportion(value: &str) -> Option<(f32, f32)> {
Some(match value {
"square" | "square_hd" => (1.0, 1.0),
"wide" => (16.0, 10.0),
"wide_five_by_four" => (5.0, 4.0),
"wide_four_by_three" => (4.0, 3.0),
"wide_three_by_two" => (3.0, 2.0),
"wide_sixteen_by_nine" => (16.0, 9.0),
"wide_twenty_one_by_nine" => (21.0, 9.0),
"tall" => (10.0, 16.0),
"tall_four_by_five" => (4.0, 5.0),
"tall_three_by_four" => (3.0, 4.0),
"tall_two_by_three" => (2.0, 3.0),
"tall_nine_by_sixteen" => (9.0, 16.0),
"tall_nine_by_twenty_one" => (9.0, 21.0),
_ => return None,
})
}
/// Resolution labels (`ResolutionPicker`).
pub fn resolution_label(value: &str) -> String {
match value {
"half_k" => "0.5K",
"four_eighty_p" => "480p",
"seven_twenty_p" => "720p",
"one_k" => "1K",
"ten_eighty_p" => "1080p",
"two_k" => "2K",
"three_k" => "3K",
"four_k" => "4K",
other => other,
}
.to_owned()
}
/// Whether a resolution is high definition (2K and up), for its SD/HD caption.
pub fn resolution_is_hd(value: &str) -> bool {
matches!(value, "two_k" | "three_k" | "four_k")
}
/// Quality labels (`QualityPicker`).
pub fn quality_label(value: &str) -> String {
match value {
"auto" => "Auto",
"max" => "Max",
"xhigh" => "Extra high",
"high" => "High",
"medium" => "Medium",
"low" => "Low",
other => other,
}
.to_owned()
}
/// Bitrate labels.
pub fn bitrate_label(value: &str) -> String {
match value {
"normal" => "Normal".to_owned(),
"high" => "High".to_owned(),
other => other.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn model(id: &str) -> ModelInfo {
ModelInfo { id: id.to_owned(), name: display_name(id), family: family(id).to_owned(), batch_default: 1, ..Default::default() }
}
mod naming {
use super::*;
#[test]
fn humanizes_versions_and_known_words() {
assert_eq!(display_name("seedance_2p0_fast"), "Seedance 2.0 Fast");
assert_eq!(display_name("veo_3p1"), "Google Veo 3.1");
assert_eq!(display_name("gpt_image_2p5_flare"), "GPT Image 2.5 Flare");
assert_eq!(display_name("kling_3p0_pro"), "Kling 3.0 Pro");
assert_eq!(display_name("nano_banana"), "Nano Banana");
assert_eq!(display_name("seedance_2p0_bpu"), "Seedance 2.0 Plus Ultra");
}
#[test]
fn prefers_curated_names_and_the_real_maker() {
let nb = base("nano_banana_pro", MediaKind::Image, Some("Nano Banana Pro"), Some("artcraft".into()));
assert_eq!((nb.name.as_str(), nb.creator.as_str()), ("Nano Banana Pro", "google"));
let flux = base("flux_1_dev", MediaKind::Image, Some("FLUX.1 [dev]"), Some("black_forest_labs".into()));
assert_eq!(flux.name, "Flux 1 Dev");
let mj = base("midjourney_7_niji", MediaKind::Image, Some("Midjourney v7 Niji (Anime)"), Some("midjourney".into()));
assert_eq!((mj.name.as_str(), mj.creator.as_str()), ("Midjourney v7 Niji (Anime)", "midjourney"));
}
#[test]
fn falls_back_to_creator_by_prefix() {
assert_eq!(creator_for("seedream_4p5"), "bytedance");
assert_eq!(creator_for("veo_3"), "google");
assert_eq!(creator_for("something_new"), "artcraft");
}
#[test]
fn prompt_limits_follow_the_static_lists() {
assert_eq!(default_prompt_max("nano_banana_pro"), None);
assert_eq!(default_prompt_max("seedance_2p0"), None);
assert_eq!(default_prompt_max("seedance_1p5_pro"), Some(3000));
assert_eq!(default_prompt_max("grok_imagine_video_1p5"), Some(4096));
assert_eq!(default_prompt_max("grok_imagine_image"), Some(8000));
assert_eq!(default_prompt_max("unknown_model"), Some(DEFAULT_PROMPT_MAX));
}
}
mod options {
use super::*;
#[test]
fn resolve_prefers_current_then_default_then_first() {
let opts = vec!["square".to_owned(), "wide_sixteen_by_nine".to_owned()];
let default = "wide_sixteen_by_nine".to_owned();
assert_eq!(ModelInfo::resolve(Some("square"), &opts, Some(&default)).as_deref(), Some("square"));
assert_eq!(ModelInfo::resolve(Some("tall"), &opts, Some(&default)).as_deref(), Some("wide_sixteen_by_nine"));
assert_eq!(ModelInfo::resolve(None, &opts, None).as_deref(), Some("square"));
assert_eq!(ModelInfo::resolve(None, &[], None), None);
}
#[test]
fn batch_counts_snap_to_valid_values() {
let mut m = model("grok_imagine_image");
m.batch_sizes = vec![6];
m.batch_default = 6;
assert_eq!(m.valid_batch(1), 6);
m.batch_sizes = vec![1, 2, 3, 4];
m.batch_default = 1;
assert_eq!(m.valid_batch(3), 3);
assert_eq!(m.valid_batch(9), 1);
}
#[test]
fn durations_clamp_to_ranges_and_snap_to_options() {
let mut m = model("seedance_2p0");
m.duration_min = Some(4);
m.duration_max = Some(15);
m.duration_max_with_refs = Some(10);
m.duration_default = Some(5);
assert_eq!(m.resolve_duration(None, false), Some(5));
assert_eq!(m.resolve_duration(Some(20), false), Some(15));
assert_eq!(m.resolve_duration(Some(14), true), Some(10));
assert_eq!(m.duration_range(false), Some((4, 15)));
let mut m = model("sora_2");
m.duration_options = vec![4, 8, 12];
m.duration_default = Some(4);
assert_eq!(m.resolve_duration(Some(6), false), Some(8));
assert_eq!(m.resolve_duration(Some(20), false), Some(12));
assert_eq!(m.duration_range(false), Some((4, 12)));
}
}
mod grouping {
use super::*;
#[test]
fn groups_large_lists_by_family_newest_first() {
let ids = ["nano_banana", "nano_banana_pro", "nano_banana_2", "seedream_4", "seedream_4p5", "flux_1_dev", "gpt_image_1", "gpt_image_2"];
let models: Vec<ModelInfo> = ids.iter().map(|id| model(id)).collect();
let families = grouped(&models).expect("8 models group");
let names: Vec<&str> = families.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Nano Banana", "Seedream", "GPT Image", "Flux"]);
let seedream: Vec<&str> = families[1].models.iter().map(|m| m.name.as_str()).collect();
assert_eq!(seedream, ["Seedream 4.5", "Seedream 4"]);
}
#[test]
fn small_lists_stay_flat() {
let models: Vec<ModelInfo> = ["veo_3", "sora_2"].iter().map(|id| model(id)).collect();
assert!(grouped(&models).is_none());
}
}
}

View File

@ -0,0 +1,204 @@
//! The sign-in dialog and the settings dialog.
use egui::{Color32, Id, Sense, Stroke, Ui, pos2, vec2};
use crate::shell::{WEB_APP_URL, group_thousands};
use crate::theme;
use crate::ui::icons::Icon;
use crate::ui::widgets;
#[derive(Default)]
pub struct LoginDialog {
pub username: String,
pub password: String,
pub error: Option<String>,
pub busy: bool,
/// A browser sign-in waiting for approval: (page, confirmation code).
pub device: Option<(String, String)>,
}
#[derive(Debug, PartialEq)]
pub enum LoginAction {
Close,
Password,
Browser,
CancelBrowser,
}
impl LoginDialog {
pub fn show(&mut self, ctx: &egui::Context) -> Option<LoginAction> {
let mut action = None;
let close = widgets::modal(ctx, Id::new("login"), 420.0, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Sign in to ArtCraft", 24.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
action = Some(LoginAction::Close);
}
});
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Use your ArtCraft account to generate images and videos.").color(theme::MUTED));
ui.add_space(16.0);
let width = ui.available_width();
match &self.device {
Some((url, code)) => {
widgets::hud(ui, "Confirmation code", theme::FAINT);
ui.add_space(6.0);
ui.label(egui::RichText::new(code).size(28.0).family(theme::mono_bold()).color(theme::INK).extra_letter_spacing(4.0));
ui.add_space(6.0);
ui.label(egui::RichText::new("Approve the sign-in in your browser and check the code matches. This window updates by itself.").color(theme::MUTED));
ui.add_space(10.0);
ui.horizontal(|ui| {
if widgets::button(ui, Some(Icon::ExternalLink), "Open page again", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
action = Some(LoginAction::CancelBrowser);
}
});
},
None => {
if ui.add_enabled_ui(!self.busy, |ui| widgets::button(ui, Some(Icon::Globe), "Sign in with browser", widgets::Kind::Primary, width, 40.0)).inner.clicked() {
action = Some(LoginAction::Browser);
}
ui.add_space(14.0);
divider_with_label(ui, "or");
ui.add_space(14.0);
widgets::hud(ui, "Username or email", theme::FAINT);
ui.add_space(4.0);
let user_resp = text_field(ui, &mut self.username, false, "you@example.com");
ui.add_space(10.0);
widgets::hud(ui, "Password", theme::FAINT);
ui.add_space(4.0);
let pass_resp = text_field(ui, &mut self.password, true, "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}");
let submitted = (user_resp.lost_focus() || pass_resp.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(14.0);
let ready = !self.username.trim().is_empty() && !self.password.is_empty() && !self.busy;
let clicked = ui.add_enabled_ui(ready, |ui| widgets::button(ui, if self.busy { None } else { Some(Icon::LogIn) }, if self.busy { "Signing in\u{2026}" } else { "Sign in" }, widgets::Kind::Secondary, width, 40.0)).inner.clicked();
if ready && (clicked || submitted) {
action = Some(LoginAction::Password);
}
},
}
if let Some(error) = &self.error {
ui.add_space(10.0);
ui.label(egui::RichText::new(error).color(theme::BAD));
}
ui.add_space(16.0);
ui.horizontal(|ui| {
widgets::hud_link(ui, "Create account", &format!("{WEB_APP_URL}/signup"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
widgets::hud_link(ui, "Forgot password", &format!("{WEB_APP_URL}/forgot-password"));
});
});
});
if close && action.is_none() {
action = Some(if self.device.is_some() { LoginAction::CancelBrowser } else { LoginAction::Close });
}
action
}
}
#[derive(Debug, PartialEq)]
pub enum SettingsAction {
Close,
SignOut,
SignIn,
}
/// The settings dialog (Settings → Misc and Account in the webapp).
pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option<u64>, data_root: &std::path::Path) -> Option<SettingsAction> {
let mut action = None;
let close = widgets::modal(ctx, Id::new("settings"), 520.0, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Settings", 24.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
action = Some(SettingsAction::Close);
}
});
});
ui.add_space(14.0);
widgets::hud(ui, "General", theme::FAINT);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(400.0);
ui.label(egui::RichText::new("Enter to generate").size(14.0).family(theme::medium()));
ui.label(egui::RichText::new("When on, pressing Enter submits the prompt and Shift+Enter adds a new line. When off (default), both Enter and Shift+Enter add a new line - use only the button to submit.").size(12.5).color(theme::MUTED));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if switch(ui, *enter_to_generate).clicked() {
*enter_to_generate = !*enter_to_generate;
}
});
});
ui.add_space(18.0);
widgets::hud(ui, "Account", theme::FAINT);
ui.add_space(6.0);
match username {
Some(user) => {
ui.label(egui::RichText::new(format!("Signed in as @{user}")).size(14.0));
if let Some(c) = credits {
ui.label(egui::RichText::new(format!("{} credits", group_thousands(c))).size(12.5).color(theme::MUTED));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if widgets::button(ui, Some(Icon::Gem), "Buy credits", widgets::Kind::Accent, 0.0, theme::CONTROL_H).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing")));
}
if widgets::button(ui, Some(Icon::LogOut), "Log out", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
action = Some(SettingsAction::SignOut);
}
});
},
None => {
if widgets::button(ui, Some(Icon::LogIn), "Sign in", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() {
action = Some(SettingsAction::SignIn);
}
},
}
ui.add_space(18.0);
widgets::hud(ui, "About", theme::FAINT);
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("ArtCraft native {}", env!("CARGO_PKG_VERSION"))).size(13.0).color(theme::MUTED));
if widgets::button(ui, Some(Icon::Folder), "Open data folder", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
if let Err(err) = open::that(data_root) {
log::warn!("Couldn't open {}: {err}", data_root.display());
}
}
});
if close && action.is_none() {
action = Some(SettingsAction::Close);
}
action
}
/// A single-line input on the sunken surface with a hairline (and a blue line while focused).
fn text_field(ui: &mut Ui, value: &mut String, password: bool, hint: &str) -> egui::Response {
let id = ui.next_auto_id();
let focused = ui.memory(|m| m.has_focus(id));
egui::Frame::new().fill(theme::SUNKEN).stroke(Stroke::new(1.0, if focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| ui.add(egui::TextEdit::singleline(value).id(id).password(password).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new(hint).color(theme::FAINT)))).inner
}
/// An on/off switch.
fn switch(ui: &mut Ui, on: bool) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 20.0), Sense::click());
let t = ui.ctx().animate_bool(resp.id, on);
let fill = if on { theme::ACCENT } else { theme::WASH_HOVER };
ui.painter().rect(rect, egui::CornerRadius::same(10), fill, Stroke::new(1.0, theme::LINE), egui::StrokeKind::Inside);
let x = egui::lerp(rect.left() + 10.0..=rect.right() - 10.0, t);
ui.painter().circle_filled(pos2(x, rect.center().y), 7.0, Color32::WHITE);
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn divider_with_label(ui: &mut Ui, label: &str) {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::hover());
let galley = widgets::caps(ui, label, 10.0, theme::mono(), 1.5, theme::FAINT);
let gap = galley.size().x / 2.0 + 10.0;
let p = ui.painter();
p.hline(rect.left()..=rect.center().x - gap, rect.center().y, theme::hairline());
p.hline(rect.center().x + gap..=rect.right(), rect.center().y, theme::hairline());
p.galley(rect.center() - galley.size() / 2.0, galley, theme::FAINT);
}

View File

@ -0,0 +1,197 @@
//! "Pick from library" (`GalleryModal mode="select"`): the user's images, videos or audio in a
//! grid, choosing up to the free reference slots.
use std::collections::HashSet;
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::backend::wire::MediaFile;
use crate::feed::types::MediaKind;
use crate::prompt_box::deck::cover_uv;
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
const TILE_GAP: f32 = 8.0;
const COLUMNS: usize = 5;
pub struct LibraryPicker {
pub kind: RefKind,
pub slot: ImageSlot,
/// Which page asked (the picked media goes back there).
pub page: MediaKind,
pub max: usize,
pub items: Vec<MediaFile>,
pub loading: bool,
pub has_more: bool,
pub next_page: u32,
/// Already attached: shown but not selectable.
pub attached: HashSet<String>,
selected: Vec<String>,
}
#[derive(Debug)]
pub enum PickerAction {
Close,
Confirm(Vec<RefMedia>),
LoadMore,
}
impl LibraryPicker {
pub fn new(kind: RefKind, slot: ImageSlot, page: MediaKind, max: usize, attached: HashSet<String>) -> Self {
Self { kind, slot, page, max: max.max(1), items: Vec::new(), loading: false, has_more: true, next_page: 0, attached, selected: Vec::new() }
}
/// The library's media class filter for this picker.
pub fn media_class(&self) -> &'static str {
match self.kind {
RefKind::Image => "image",
RefKind::Video => "video",
RefKind::Audio => "audio",
}
}
pub fn apply_page(&mut self, page: u32, files: Vec<MediaFile>, total_pages: u32) {
self.loading = false;
if page != self.next_page {
return;
}
for f in files {
if !self.items.iter().any(|i| i.token == f.token) {
self.items.push(f);
}
}
self.next_page = page + 1;
self.has_more = self.next_page < total_pages;
}
pub fn show(&mut self, ctx: &egui::Context, cache: &mut MediaCache) -> Vec<PickerAction> {
let mut actions = Vec::new();
let screen = ctx.content_rect();
let width = (screen.width() - 80.0).min(960.0);
let height = (screen.height() - 120.0).min(680.0);
let noun = match self.kind {
RefKind::Image => "images",
RefKind::Video => "videos",
RefKind::Audio => "audio",
};
let close = widgets::modal(ctx, Id::new("library-picker"), width, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Pick from library", 22.0));
ui.add_space(8.0);
widgets::hud(ui, noun, theme::MUTED);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
actions.push(PickerAction::Close);
}
});
});
ui.add_space(10.0);
let grid_h = height - 120.0;
egui::ScrollArea::vertical().max_height(grid_h).min_scrolled_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let w = ui.available_width();
let tile = (w - TILE_GAP * (COLUMNS - 1) as f32) / COLUMNS as f32;
let rows = self.items.len().div_ceil(COLUMNS);
let total = rows as f32 * (tile + TILE_GAP);
let origin = ui.min_rect().min;
ui.allocate_exact_size(vec2(w, total + 48.0), Sense::hover());
let mut toggled = None;
for (i, item) in self.items.iter().enumerate() {
let local = Rect::from_min_size(pos2((i % COLUMNS) as f32 * (tile + TILE_GAP), (i / COLUMNS) as f32 * (tile + TILE_GAP)), vec2(tile, tile));
if !local.intersects(viewport.expand(tile)) {
continue;
}
let rect = local.translate(origin.to_vec2());
let attached = self.attached.contains(&item.token);
let order = self.selected.iter().position(|t| *t == item.token);
if tile_ui(ui, rect, item, self.kind, attached, order, cache) {
toggled = Some(item.token.clone());
}
}
if let Some(token) = toggled {
self.toggle(token);
}
if self.items.is_empty() && !self.loading && !self.has_more {
ui.painter().text(origin + vec2(w / 2.0, 80.0), Align2::CENTER_CENTER, format!("No {noun} in your library yet"), FontId::new(14.0, egui::FontFamily::Proportional), theme::MUTED);
}
if self.loading {
icons::paint_spinner(ui, Rect::from_center_size(origin + vec2(w / 2.0, total + 24.0), vec2(24.0, 24.0)), theme::MUTED);
} else if self.has_more && viewport.max.y + 300.0 >= total {
actions.push(PickerAction::LoadMore);
}
});
ui.add_space(12.0);
ui.horizontal(|ui| {
widgets::hud(ui, &format!("{}/{} selected", self.selected.len(), self.max), theme::MUTED);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let any = !self.selected.is_empty();
if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Use selected", widgets::Kind::Primary, 0.0, theme::CONTROL_H)).inner.clicked() {
actions.push(PickerAction::Confirm(self.picks()));
}
if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Deselect all", widgets::Kind::Secondary, 0.0, theme::CONTROL_H)).inner.clicked() {
self.selected.clear();
}
});
});
});
if close {
actions.push(PickerAction::Close);
}
actions
}
fn toggle(&mut self, token: String) {
if let Some(i) = self.selected.iter().position(|t| *t == token) {
self.selected.remove(i);
} else if self.max == 1 {
self.selected = vec![token];
} else if self.selected.len() < self.max {
self.selected.push(token);
}
}
fn picks(&self) -> Vec<RefMedia> {
self.selected.iter().filter_map(|t| self.items.iter().find(|i| i.token == *t)).map(|f| RefMedia::from_library(self.kind, f.token.clone(), f.media_links.thumbnail(256), Some(f.media_links.cdn_url.clone()), f.maybe_duration_millis.map_or(0.0, |ms| ms as f32 / 1000.0))).collect()
}
}
/// One square tile; returns whether it was clicked (and is selectable).
fn tile_ui(ui: &mut egui::Ui, rect: Rect, item: &MediaFile, kind: RefKind, attached: bool, order: Option<usize>, cache: &mut MediaCache) -> bool {
let resp = ui.interact(rect, Id::new(("picker-tile", &item.token)), if attached { Sense::hover() } else { Sense::click() });
let p = ui.painter();
p.rect_filled(rect, 0.0, theme::fade(theme::CONTROLS, 0.6));
match item.media_links.thumbnail(256).map(|u| cache.get(ui.ctx(), &u)) {
Some(Lookup::Ready(t)) => {
let image = egui::Image::new(&t).uv(cover_uv(t.size_vec2()));
let image = if attached { image.tint(Color32::from_gray(90)) } else { image };
image.paint_at(ui, rect);
},
_ => {
let icon = match kind {
RefKind::Image => Icon::Image,
RefKind::Video => Icon::Video,
RefKind::Audio => Icon::Music,
};
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(26.0, 26.0)), icon, theme::FAINT);
},
}
if let Some(ms) = item.maybe_duration_millis.filter(|_| kind != RefKind::Image) {
let strip = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 18.0), rect.max);
ui.painter().rect_filled(strip, 0.0, Color32::from_black_alpha(178));
ui.painter().text(strip.center(), Align2::CENTER_CENTER, format!("{:.0}s", ms as f32 / 1000.0), FontId::new(11.0, theme::semibold()), Color32::WHITE);
}
let p = ui.painter();
if attached {
p.text(rect.center(), Align2::CENTER_CENTER, "Attached", FontId::new(11.0, theme::semibold()), theme::fade(Color32::WHITE, 0.7));
} else if let Some(i) = order {
p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside);
let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(22.0, 22.0));
p.rect_filled(chip, theme::RADIUS, theme::ACCENT_400);
p.text(chip.center(), Align2::CENTER_CENTER, (i + 1).to_string(), FontId::new(11.0, theme::semibold()), Color32::WHITE);
} else if resp.hovered() {
p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6)), egui::StrokeKind::Inside);
}
!attached && resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}

View File

@ -0,0 +1,277 @@
//! The lightbox (`lightbox-modal`): the media large on black, prev/next through the feed, and a
//! side column with the prompt, details and actions.
use egui::{Align2, Color32, FontId, Id, Key, Rect, Sense, Ui, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::backend::wire::Prompt;
use crate::feed::grid::ModelNames;
use crate::feed::types::{FeedItem, MediaKind};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::{creator_icons, widgets};
const SIDE_WIDTH: f32 = 280.0;
pub struct Lightbox {
pub kind: MediaKind,
pub token: String,
confirm_delete: bool,
prompt_expanded: bool,
}
#[derive(Debug, PartialEq)]
pub enum LightboxAction {
Close,
Navigate(String),
Recreate,
MakeVideo,
Download,
Share,
Delete,
CopyPrompt(String),
Play(String),
}
impl Lightbox {
pub fn new(kind: MediaKind, token: String) -> Self {
Self { kind, token, confirm_delete: false, prompt_expanded: false }
}
pub fn show(&mut self, ctx: &egui::Context, item: &FeedItem, order: &[String], prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache) -> Vec<LightboxAction> {
let mut actions = Vec::new();
let screen = ctx.content_rect();
let size = vec2((screen.width() - 80.0).min(1200.0), (screen.height() - 80.0).min(760.0));
let index = order.iter().position(|t| *t == item.token);
let prev = index.and_then(|i| i.checked_sub(1)).map(|i| order[i].clone());
let next = index.and_then(|i| order.get(i + 1)).cloned();
let typing = ctx.memory(|m| m.focused().is_some());
if !typing && !self.confirm_delete {
let (left, right) = ctx.input(|i| (i.key_pressed(Key::ArrowLeft) || i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowRight) || i.key_pressed(Key::ArrowDown)));
if let (true, Some(t)) = (left, &prev) {
actions.push(LightboxAction::Navigate(t.clone()));
}
if let (true, Some(t)) = (right, &next) {
actions.push(LightboxAction::Navigate(t.clone()));
}
}
let close = widgets::modal(ctx, Id::new("lightbox"), size.x, |ui| {
ui.set_height(size.y - 32.0);
ui.horizontal_top(|ui| {
let media_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width() - SIDE_WIDTH - 16.0, size.y - 32.0));
ui.allocate_rect(media_rect, Sense::hover());
self.media(ui, media_rect, item, cache, &prev, &next, &mut actions);
ui.add_space(16.0);
ui.vertical(|ui| {
ui.set_width(SIDE_WIDTH);
self.side(ui, item, prompt, names, cache, &mut actions);
});
});
});
if close && !self.confirm_delete {
actions.push(LightboxAction::Close);
}
if self.confirm_delete {
self.confirm(ctx, &mut actions);
}
actions
}
#[allow(clippy::too_many_arguments)]
fn media(&self, ui: &mut Ui, rect: Rect, item: &FeedItem, cache: &mut MediaCache, prev: &Option<String>, next: &Option<String>, actions: &mut Vec<LightboxAction>) {
ui.painter().rect_filled(rect, 0.0, Color32::BLACK);
let url = if item.kind == MediaKind::Image { Some(item.full_url.as_str()) } else { item.thumbnail.as_deref() };
match url.map(|u| cache.get(ui.ctx(), u)) {
Some(Lookup::Ready(texture)) => {
let fit = fit_inside(texture.size_vec2(), rect.shrink(8.0).size());
egui::Image::new(&texture).paint_at(ui, Rect::from_center_size(rect.center(), fit));
},
Some(Lookup::Loading) => {
// Show the thumbnail while the full image loads.
if let Some(Lookup::Ready(t)) = item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
let fit = fit_inside(t.size_vec2(), rect.shrink(8.0).size());
egui::Image::new(&t).paint_at(ui, Rect::from_center_size(rect.center(), fit));
}
icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(32.0, 32.0)), theme::MUTED);
},
_ => {
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "Image not available", FontId::new(14.0, egui::FontFamily::Proportional), theme::FAINT);
},
}
if item.kind == MediaKind::Video {
let play = Rect::from_center_size(rect.center(), vec2(72.0, 72.0));
let resp = ui.interact(play, Id::new(("lightbox-play", &item.token)), Sense::click()).on_hover_text("Play in your video player").on_hover_cursor(egui::CursorIcon::PointingHand);
ui.painter().circle_filled(play.center(), 36.0, Color32::from_black_alpha(if resp.hovered() { 200 } else { 150 }));
icons::paint(ui.painter(), play.shrink(22.0).translate(vec2(3.0, 0.0)), Icon::Play, Color32::WHITE);
if resp.clicked() {
actions.push(LightboxAction::Play(item.full_url.clone()));
}
}
let hovering = ui.rect_contains_pointer(rect);
for (target, left, tip) in [(prev, true, "Previous item"), (next, false, "Next item")] {
let Some(token) = target else {
continue;
};
let x = if left { rect.left() + 28.0 } else { rect.right() - 28.0 };
let button = Rect::from_center_size(pos2(x, rect.center().y), vec2(40.0, 40.0));
let resp = ui.interact(button, Id::new(("lightbox-nav", tip)), Sense::click()).on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand);
if hovering || resp.hovered() {
ui.painter().circle_filled(button.center(), 20.0, Color32::from_black_alpha(if resp.hovered() { 210 } else { 140 }));
icons::paint(ui.painter(), button.shrink(11.0), if left { Icon::ChevronLeft } else { Icon::ChevronRight }, Color32::WHITE);
}
if resp.clicked() {
actions.push(LightboxAction::Navigate(token.clone()));
}
}
}
fn side(&mut self, ui: &mut Ui, item: &FeedItem, prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache, actions: &mut Vec<LightboxAction>) {
ui.horizontal(|ui| {
widgets::hud(ui, item.kind.label(), theme::MUTED);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
actions.push(LightboxAction::Close);
}
});
});
ui.add_space(8.0);
let text = prompt.and_then(|p| p.maybe_positive_prompt.clone()).filter(|t| !t.is_empty());
ui.horizontal(|ui| {
widgets::hud(ui, "Prompt", theme::FAINT);
if let Some(t) = &text {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new("Copy").size(11.0)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
actions.push(LightboxAction::CopyPrompt(t.clone()));
}
});
}
});
match (&text, item.prompt_token.is_some(), prompt.is_some()) {
(Some(t), _, _) => {
let mut job = egui::text::LayoutJob::simple(t.clone(), FontId::new(13.0, egui::FontFamily::Proportional), theme::INK, ui.available_width());
if !self.prompt_expanded {
job.wrap.max_rows = 4;
}
let galley = ui.painter().layout_job(job);
let clipped = galley.elided;
ui.label(galley);
if clipped || self.prompt_expanded {
let label = if self.prompt_expanded { "Hide" } else { "See all" };
if ui.add(egui::Button::new(egui::RichText::new(label).size(11.5).color(theme::ACCENT_INK)).frame(false)).clicked() {
self.prompt_expanded = !self.prompt_expanded;
}
}
},
(None, true, false) => {
let (rect, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint_spinner(ui, rect, theme::FAINT);
},
_ => {
ui.label(egui::RichText::new("No prompt").size(13.0).color(theme::FAINT));
},
}
ui.add_space(14.0);
widgets::hud(ui, "Information", theme::FAINT);
ui.add_space(4.0);
if let Some(model) = item.model_id.as_deref() {
info_row(ui, "Model", |ui| {
let tex = creator_icons::texture(ui.ctx(), &names.creator(model));
ui.add(egui::Image::new(&tex).tint(theme::INK).fit_to_exact_size(vec2(13.0, 13.0)));
ui.label(egui::RichText::new(names.display_name(model)).size(12.5));
});
}
if let Some(p) = prompt {
if let Some(ar) = &p.maybe_aspect_ratio {
info_row(ui, "Aspect Ratio", |ui| {
ui.label(egui::RichText::new(crate::models::aspect_label(ar)).size(12.5));
});
}
if let Some(res) = &p.maybe_resolution {
info_row(ui, "Resolution", |ui| {
ui.label(egui::RichText::new(crate::models::resolution_label(res)).size(12.5));
});
}
}
if let Some(d) = item.duration_secs {
info_row(ui, "Duration", |ui| {
ui.label(egui::RichText::new(format!("{d:.1}s")).size(12.5));
});
}
if let Some(Lookup::Ready(t)) = (item.kind == MediaKind::Image).then(|| cache.get(ui.ctx(), &item.full_url)) {
let s = t.size();
info_row(ui, "Size", |ui| {
ui.label(egui::RichText::new(format!("{} \u{d7} {}", s[0], s[1])).size(12.5));
});
}
if let Some(created) = chrono::DateTime::from_timestamp(item.created_at, 0) {
let local = created.with_timezone(&chrono::Local);
info_row(ui, "Created", |ui| {
ui.label(egui::RichText::new(local.format("%b %-d, %Y %-I:%M:%S %p").to_string()).size(12.5));
});
}
ui.add_space(16.0);
let w = (ui.available_width() - 8.0) / 2.0;
if item.prompt_token.is_some() && widgets::button(ui, Some(Icon::RotateCw), "Recreate", widgets::Kind::Primary, ui.available_width(), theme::CONTROL_H).clicked() {
actions.push(LightboxAction::Recreate);
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
if item.kind == MediaKind::Image && widgets::button(ui, Some(Icon::Video), "Make Video", widgets::Kind::Accent, w, theme::CONTROL_H).clicked() {
actions.push(LightboxAction::MakeVideo);
}
if widgets::button(ui, Some(Icon::Download), "Download", widgets::Kind::Secondary, w, theme::CONTROL_H).clicked() {
actions.push(LightboxAction::Download);
}
if widgets::button(ui, Some(Icon::Link), "Share", widgets::Kind::Secondary, w, theme::CONTROL_H).clicked() {
actions.push(LightboxAction::Share);
}
if widgets::button(ui, Some(Icon::Trash), "Delete", widgets::Kind::Danger, w, theme::CONTROL_H).clicked() {
self.confirm_delete = true;
}
});
}
fn confirm(&mut self, ctx: &egui::Context, actions: &mut Vec<LightboxAction>) {
let mut choice = None;
let close = widgets::modal(ctx, Id::new("lightbox-delete"), 420.0, |ui| {
ui.label(widgets::heading("Delete this media?", 20.0));
ui.add_space(8.0);
ui.label(egui::RichText::new("This will permanently remove the media from your library. This action cannot be undone.").color(theme::MUTED));
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, None, "Delete", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {
choice = Some(true);
}
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
choice = Some(false);
}
});
});
if close || choice.is_some() {
self.confirm_delete = false;
}
if choice == Some(true) {
actions.push(LightboxAction::Delete);
}
}
}
fn info_row(ui: &mut Ui, label: &str, value: impl FnOnce(&mut Ui)) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::hover());
ui.painter().text(rect.left_center(), Align2::LEFT_CENTER, label, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED);
value(ui);
});
}
/// The largest size with `content`'s aspect that fits in `bounds`.
pub fn fit_inside(content: egui::Vec2, bounds: egui::Vec2) -> egui::Vec2 {
if content.x <= 0.0 || content.y <= 0.0 {
return bounds;
}
let scale = (bounds.x / content.x).min(bounds.y / content.y);
content * scale
}

View File

@ -0,0 +1,3 @@
pub mod account;
pub mod library_picker;
pub mod lightbox;

View File

@ -0,0 +1,345 @@
//! What the create pages share: the page shell (`DesktopCreatePageShell`), uploading references,
//! cost-estimate bookkeeping and the app-level requests pages make.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use egui::{Align2, Color32, Id, Mesh, Order, Rect, Ui, pos2, vec2};
use serde_json::{Map, Value};
use crate::backend::media_cache::MediaCache;
use crate::backend::{Backend, Modality, UploadedMedia};
use crate::feed::grid::{self, FeedAction, FeedView, RatioCache, ViewMode};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::models::Catalog;
use crate::prompt_box;
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, RefStatus, References};
use crate::theme;
use crate::ui::toast::Toasts;
use crate::ui::widgets;
/// How long settings must sit still before a cost estimate is requested.
const COST_DEBOUNCE_SECS: f64 = 0.3;
/// Gap between the prompt box and the window's bottom edge (`bottom-4`).
const BOX_BOTTOM: f32 = 16.0;
/// The app services a page can use this frame.
pub struct Env<'a> {
pub ctx: egui::Context,
pub backend: &'a Backend,
pub cache: &'a mut MediaCache,
pub toasts: &'a mut Toasts,
pub catalog: &'a Catalog,
pub signed_in: bool,
pub enter_to_generate: bool,
pub view_mode: ViewMode,
pub ratios: &'a mut RatioCache,
pub prompts: &'a std::collections::HashMap<String, String>,
pub requests: &'a mut Vec<AppRequest>,
}
/// Things only the app can do.
#[derive(Debug)]
pub enum AppRequest {
SignIn,
Open { kind: MediaKind, token: String },
PickFromLibrary { kind: RefKind, slot: ImageSlot, max: usize, page: MediaKind },
MakeVideo { token: String },
Recreate { kind: MediaKind, token: String },
Share(String),
Download(Vec<String>),
CopyText(String),
Preview(String),
LoadMoreLibrary(FeedKey),
}
/// Which feed a library page is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FeedKey {
Image,
Video,
Library,
}
impl FeedKey {
pub fn of(kind: MediaKind) -> Self {
match kind {
MediaKind::Image => FeedKey::Image,
MediaKind::Video => FeedKey::Video,
}
}
/// The library's media class filter.
pub fn media_classes(self) -> &'static str {
match self {
FeedKey::Image => "image",
FeedKey::Video => "video",
FeedKey::Library => "image,video",
}
}
}
/// The page around the feed: the splash empty state (or the feed), a fade behind the prompt box,
/// and the prompt box (drawn by `prompt_box`) floating at the bottom centre.
#[allow(clippy::too_many_arguments)]
pub fn create_shell(ui: &mut Ui, env: &mut Env<'_>, kind: MediaKind, title: &str, subtitle: &str, feed: &mut FeedStore, box_height: &mut f32, prompt_box: impl FnOnce(&mut Ui, &mut Env<'_>)) {
let area = ui.max_rect();
let bottom_offset = *box_height + 40.0;
if feed.has_content() {
let view = FeedView { id: Id::new(("feed", kind.label())), mode: env.view_mode, pending: &feed.pending, failed: &feed.failed, items: &feed.items, has_more: feed.has_more, loading: feed.loading, selecting: feed.selecting, selected: &feed.selected, last_viewed: feed.last_viewed.as_deref(), prompts: env.prompts, make_video: kind == MediaKind::Image, bottom_padding: bottom_offset + 24.0 };
let mut feed_ui = ui.new_child(egui::UiBuilder::new().max_rect(area.shrink2(vec2(12.0, 0.0))));
let actions = grid::show(&mut feed_ui, &view, env.cache, env.ratios, env.catalog);
handle_feed_actions(env, kind, feed, actions);
bottom_fade(ui, area);
} else {
empty_state(ui, env, area, bottom_offset, title, subtitle);
}
if env.signed_in && !feed.library_started() {
env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::of(kind)));
}
if feed.selecting {
selection_bar(ui, area, *box_height + 32.0, feed, kind, env.requests);
}
let width = (area.width() - 32.0).min(prompt_box::MAX_WIDTH);
let pos = pos2(area.center().x, area.bottom() - BOX_BOTTOM);
let response = egui::Area::new(Id::new(("prompt-box", kind.label()))).order(Order::Middle).pivot(Align2::CENTER_BOTTOM).fixed_pos(pos).show(ui.ctx(), |ui| {
ui.set_width(width);
prompt_box(ui, env);
});
*box_height = response.response.rect.height();
}
fn handle_feed_actions(env: &mut Env<'_>, kind: MediaKind, feed: &mut FeedStore, actions: Vec<FeedAction>) {
for action in actions {
match action {
FeedAction::Open(token) => {
feed.last_viewed = Some(token.clone());
env.requests.push(AppRequest::Open { kind, token });
},
FeedAction::ToggleSelect(token) => {
if !feed.selected.remove(&token) {
feed.selected.insert(token);
}
},
FeedAction::DismissFailed(job) => feed.dismiss(&job),
FeedAction::LoadMore => env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::of(kind))),
FeedAction::Recreate(token) => env.requests.push(AppRequest::Recreate { kind, token }),
FeedAction::MakeVideo(token) => env.requests.push(AppRequest::MakeVideo { token }),
FeedAction::Share(token) => env.requests.push(AppRequest::Share(token)),
FeedAction::Download(token) => env.requests.push(AppRequest::Download(vec![token])),
FeedAction::CopyPrompt(text) => env.requests.push(AppRequest::CopyText(text)),
}
}
}
/// The floating "N selected · Download · ×" pill (`SelectionActionBar`), `bottom` above the
/// window's bottom edge.
pub fn selection_bar(ui: &Ui, area: Rect, bottom: f32, feed: &mut FeedStore, kind: MediaKind, requests: &mut Vec<AppRequest>) {
let pos = pos2(area.center().x, area.bottom() - bottom);
egui::Area::new(Id::new(("selection-bar", kind.label()))).order(Order::Foreground).pivot(Align2::CENTER_BOTTOM).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::new().fill(theme::fade(theme::PANEL, 0.95)).stroke(theme::hairline()).corner_radius(egui::CornerRadius::same(24)).inner_margin(egui::Margin::symmetric(10, 8)).shadow(theme::shadow()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("{} selected", feed.selected.len())).size(13.0).family(theme::medium()));
let any = !feed.selected.is_empty();
if ui.add_enabled_ui(any, |ui| widgets::button(ui, Some(crate::ui::icons::Icon::Download), "Download", widgets::Kind::Secondary, 0.0, 30.0)).inner.clicked() {
requests.push(AppRequest::Download(feed.selected.iter().cloned().collect()));
}
if widgets::ghost(ui, crate::ui::icons::Icon::X, None, "Exit selection", false).clicked() {
feed.selecting = false;
feed.selected.clear();
}
});
});
});
}
/// The splash: a bordered section with corner marks, the page title and a sign-in prompt for
/// visitors (`CreateEmptyState`).
fn empty_state(ui: &mut Ui, env: &mut Env<'_>, area: Rect, bottom_offset: f32, title: &str, subtitle: &str) {
let usable = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - bottom_offset));
let width = (usable.width() - 64.0).min(768.0);
let height = if env.signed_in { 190.0 } else { 250.0 };
let section = Rect::from_center_size(usable.center(), vec2(width, height));
let p = ui.painter();
p.rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside);
widgets::corner_marks(p, section);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 52.0))));
child.label(widgets::heading(title, 48.0));
child.add_space(12.0);
child.label(egui::RichText::new(subtitle).size(16.0).color(theme::MUTED));
if !env.signed_in {
child.add_space(24.0);
if widgets::button(&mut child, Some(crate::ui::icons::Icon::Sparkles), "Sign in to create", widgets::Kind::Primary, 0.0, 44.0).clicked() {
env.requests.push(AppRequest::SignIn);
}
}
}
/// The 192 px fade behind the floating prompt box (`h-48 from-ui-background`).
fn bottom_fade(ui: &Ui, area: Rect) {
let band = Rect::from_min_max(pos2(area.left(), area.bottom() - 192.0), area.max);
let mut mesh = Mesh::default();
let (top, bottom) = (Color32::TRANSPARENT, theme::BG);
mesh.colored_vertex(band.left_top(), top);
mesh.colored_vertex(band.right_top(), top);
mesh.colored_vertex(band.right_bottom(), bottom);
mesh.colored_vertex(band.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
// Same layer as the feed (painted after it), so it stays under the floating prompt box.
ui.painter().add(mesh);
}
/// The selected model, else the page default, else the first.
pub fn selected_model<'a>(models: &'a [crate::models::ModelInfo], selected: Option<&str>, default_id: &str) -> Option<&'a crate::models::ModelInfo> {
selected.and_then(|id| models.iter().find(|m| m.id == id)).or_else(|| models.iter().find(|m| m.id == default_id)).or_else(|| models.first())
}
// --- References -------------------------------------------------------------------------------
/// Starts uploading `path` into `slot` (or the deck for videos and audio).
pub fn upload_path(env: &mut Env<'_>, refs: &mut References, path: &Path, kind: RefKind, slot: ImageSlot) {
if kind == RefKind::Video && !path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("mp4")) {
env.toasts.error("Failed to upload video. Please upload an MP4 file.");
return;
}
let mut item = RefMedia::uploading(kind, None);
if kind == RefKind::Image {
let key = format!("local://{}", item.id);
match std::fs::read(path) {
Ok(bytes) => {
env.cache.insert_bytes(&env.ctx, &key, Arc::from(bytes));
item.preview = Some(key);
},
Err(err) => {
env.toasts.error(format!("Couldn't read {}: {err}", file_name(path)));
return;
},
}
}
env.backend.upload_file(item.id, kind, path.to_path_buf());
place(refs, item, slot);
}
/// Uploads a pasted image (PNG bytes) into `slot`.
pub fn upload_png(env: &mut Env<'_>, refs: &mut References, png: Vec<u8>, slot: ImageSlot) {
let mut item = RefMedia::uploading(RefKind::Image, None);
let key = format!("local://{}", item.id);
env.cache.insert_bytes(&env.ctx, &key, Arc::from(png.clone()));
item.preview = Some(key);
env.backend.upload_png(item.id, png);
place(refs, item, slot);
}
fn place(refs: &mut References, item: RefMedia, slot: ImageSlot) {
match (item.kind, slot) {
(RefKind::Image, ImageSlot::FirstFrame) => refs.first_frame = Some(item),
(RefKind::Image, ImageSlot::LastFrame) => refs.last_frame = Some(item),
(kind, _) => refs.list_mut(kind).push(item),
}
}
/// Applies a finished upload. Returns `false` (and drops the reference) when it failed.
pub fn apply_upload(toasts: &mut Toasts, refs: &mut References, ref_id: u64, result: Result<UploadedMedia, String>) -> bool {
match result {
Ok(media) => {
if let Some(r) = refs.find_mut(ref_id) {
r.status = RefStatus::Ready;
r.token = Some(media.token);
r.duration_secs = media.duration_secs;
r.full_url = media.full_url;
if r.preview.is_none() {
r.preview = media.thumbnail;
}
}
true
},
Err(message) => {
let kind = refs.find_mut(ref_id).map_or("file", |r| r.kind.noun());
refs.remove(ref_id);
toasts.error(format!("Failed to upload {kind}: {message}"));
false
},
}
}
/// Picked library media, ready to attach.
pub fn attach_from_library(refs: &mut References, kind: RefKind, slot: ImageSlot, picks: Vec<RefMedia>) {
for pick in picks {
let already = refs.list(kind).iter().chain(refs.first_frame.iter()).chain(refs.last_frame.iter()).any(|r| r.token == pick.token);
if !already {
place(refs, RefMedia { kind, ..pick }, slot);
}
}
}
/// Asks the user for files of `kinds` (`multiple` for decks).
pub fn pick_files(kinds: &[RefKind], multiple: bool) -> Vec<PathBuf> {
let mut dialog = rfd::FileDialog::new();
for kind in kinds {
let (name, exts): (&str, &[&str]) = match kind {
RefKind::Image => ("Images", &["png", "jpg", "jpeg", "webp", "gif", "bmp", "avif"]),
RefKind::Video => ("Videos (MP4)", &["mp4"]),
RefKind::Audio => ("Audio", &["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"]),
};
dialog = dialog.add_filter(name, exts);
}
if multiple {
dialog.pick_files().unwrap_or_default()
} else {
dialog.pick_file().into_iter().collect()
}
}
pub fn file_name(path: &Path) -> String {
path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_owned()
}
// --- Cost estimates ---------------------------------------------------------------------------
/// The latest cost estimate for a page, requested when the settings settle.
#[derive(Default)]
pub struct CostState {
/// The request the estimate below is for.
key: String,
pub credits: Option<u64>,
pending_key: Option<(String, f64)>,
}
impl CostState {
/// Requests an estimate for `fields` once they've been stable for a moment.
pub fn update(&mut self, ui: &Ui, backend: &Backend, modality: Modality, fields: &Map<String, Value>) {
let key = Value::Object(fields.clone()).to_string();
if key == self.key {
return;
}
let now = ui.input(|i| i.time);
match &self.pending_key {
Some((k, since)) if *k == key => {
if now - since >= COST_DEBOUNCE_SECS {
self.key = key.clone();
self.credits = None;
self.pending_key = None;
backend.estimate_cost(modality, key, fields.clone());
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS));
}
},
_ => {
self.pending_key = Some((key, now));
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS));
},
}
}
/// Takes an estimate that arrived for `key`.
pub fn apply(&mut self, key: &str, credits: Option<u64>) {
if key == self.key {
self.credits = credits;
}
}
}

View File

@ -0,0 +1,283 @@
//! Create Image (`TextToImage` + `PromptBoxImage`): prompt, reference images, model, aspect
//! ratio, resolution, quality and count; the cost; and the image feed.
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::wire::Prompt;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::models::{DEFAULT_IMAGE_MODEL, DEFAULT_PROMPT_MAX, ModelInfo};
use crate::pages::common::{self, AppRequest, CostState, Env};
use crate::pages::toolbar;
use crate::prompt_box::deck::{DeckAction, DeckLimits};
use crate::prompt_box::model_selector::model_selector;
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References};
use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState, editor};
const PLACEHOLDER: &str = "Describe what you want in the image...";
/// What the page remembers between launches.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ImageSettings {
pub prompt: String,
pub model: Option<String>,
pub aspect_ratio: Option<String>,
pub resolution: Option<String>,
pub quality: Option<String>,
pub count: u16,
}
#[derive(Default)]
pub struct CreateImagePage {
pub settings: ImageSettings,
pub refs: References,
pub feed: FeedStore,
pub cost: CostState,
pub generating: bool,
box_state: PromptBoxState,
box_height: f32,
}
impl CreateImagePage {
pub fn new(settings: ImageSettings) -> Self {
let mut page = Self { settings, box_height: 140.0, ..Default::default() };
page.box_state.editor.focus_requested = true;
page
}
pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) {
let models = &env.catalog.image_page;
let model = common::selected_model(models, self.settings.model.as_deref(), DEFAULT_IMAGE_MODEL).cloned();
if let Some(m) = &model {
self.reconcile(m);
let fields = self.fields(m, true);
self.cost.update(ui, env.backend, Modality::Image, &fields);
}
let mut feed = std::mem::take(&mut self.feed);
let mut box_height = self.box_height;
common::create_shell(ui, env, MediaKind::Image, "Create Image", "Add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref()));
self.feed = feed;
self.box_height = box_height;
}
fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) {
let max_images = model.map_or(0, |m| m.image_refs_max);
let deck = if max_images > 0 { DeckMode::References(DeckLimits { max_images, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: env.signed_in }) } else { DeckMode::None };
let accepts: &[RefKind] = if max_images > 0 { &[RefKind::Image] } else { &[] };
let prompt_max = model.map_or(Some(DEFAULT_PROMPT_MAX), |m| m.prompt_max);
let props = PromptBoxProps { id: Id::new("image-prompt-box"), placeholder: PLACEHOLDER, enter_to_generate: env.enter_to_generate, mentions: &[], max_length: Some(prompt_max), deck, accepts, credits: self.cost.credits, generate_enabled: !self.settings.prompt.trim().is_empty() && model.is_some(), generating: self.generating, generate_tooltip: "Generate", warning: None, banner: None };
let models = &env.catalog.image_page;
let settings = &mut self.settings;
let mut picked_model = None;
let mut left = |ui: &mut Ui| {
if let Some(id) = model_selector(ui, Id::new("image-model"), models, model, false) {
picked_model = Some(id);
}
let Some(m) = model else {
return;
};
let aspect = ModelInfo::resolve(settings.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref());
if let Some(v) = toolbar::aspect_ratio(ui, Id::new("image-aspect"), m, aspect.as_deref().unwrap_or("")) {
settings.aspect_ratio = Some(v);
}
let resolution = ModelInfo::resolve(settings.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref());
if let Some(v) = toolbar::resolution(ui, Id::new("image-resolution"), m, resolution.as_deref().unwrap_or("")) {
settings.resolution = Some(v);
}
let quality = ModelInfo::resolve(settings.quality.as_deref(), &m.qualities, m.quality_default.as_ref());
if let Some(v) = toolbar::quality(ui, Id::new("image-quality"), m, quality.as_deref().unwrap_or("")) {
settings.quality = Some(v);
}
};
let count = settings.count;
let mut picked_count = None;
let mut right = |ui: &mut Ui| {
if let Some(m) = model {
picked_count = toolbar::count(ui, Id::new("image-count"), m, count, "images");
}
};
let actions = prompt_box::show(ui, &mut settings.prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut left, &mut right);
if let Some(id) = picked_model {
self.settings.model = Some(id);
}
if let Some(n) = picked_count {
self.settings.count = n;
}
for action in actions {
self.handle(action, env, model, max_images);
}
}
fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, max_images: usize) {
match action {
PromptBoxAction::Generate => self.generate(env, model),
PromptBoxAction::ClearAll => {
self.settings.prompt.clear();
self.refs.clear();
},
PromptBoxAction::Deck(deck) => match deck {
DeckAction::Upload { .. } => {
let free = max_images.saturating_sub(self.refs.images.len());
for path in common::pick_files(&[RefKind::Image], free > 1).into_iter().take(free) {
common::upload_path(env, &mut self.refs, &path, RefKind::Image, ImageSlot::Reference);
}
},
DeckAction::Library { kind, slot } => {
env.requests.push(AppRequest::PickFromLibrary { kind, slot, max: max_images.saturating_sub(self.refs.images.len()).max(1), page: MediaKind::Image });
},
DeckAction::Remove(id) => self.refs.remove(id),
DeckAction::ClearAll => self.refs.images.clear(),
DeckAction::ReorderImages { from, to } => {
let item = self.refs.images.remove(from);
self.refs.images.insert(to.min(self.refs.images.len()), item);
},
DeckAction::Preview(id) => {
if let Some(url) = self.refs.find_mut(id).and_then(|r| r.full_url.clone().or_else(|| r.preview.clone())) {
env.requests.push(AppRequest::Preview(url));
}
},
DeckAction::SwapFrames => {},
},
PromptBoxAction::DroppedFiles(paths) => self.add_dropped(env, &paths, max_images),
PromptBoxAction::PastedImage(png) => {
if self.refs.images.len() >= max_images {
env.toasts.error(max_images_message(max_images));
} else {
common::upload_png(env, &mut self.refs, png, ImageSlot::Reference);
}
},
}
}
/// Routes dropped files: images into the deck (up to the model's limit); others get a note.
fn add_dropped(&mut self, env: &mut Env<'_>, paths: &[std::path::PathBuf], max_images: usize) {
let kinds: Vec<Option<RefKind>> = paths.iter().map(|p| RefKind::from_path(p)).collect();
let images: Vec<&std::path::PathBuf> = paths.iter().zip(&kinds).filter(|(_, k)| **k == Some(RefKind::Image)).map(|(p, _)| p).collect();
let has_video = kinds.contains(&Some(RefKind::Video));
let has_audio = kinds.contains(&Some(RefKind::Audio));
if has_video || has_audio {
let what = match (has_video, has_audio) {
(true, true) => "Video or audio",
(true, false) => "Video",
_ => "Audio",
};
env.toasts.error(format!("{what} references aren't available here"));
} else if images.is_empty() {
env.toasts.error("Only image files can be added here");
}
if images.is_empty() {
return;
}
let free = max_images.saturating_sub(self.refs.images.len());
if free == 0 {
env.toasts.error(max_images_message(max_images));
return;
}
for path in images.into_iter().take(free) {
common::upload_path(env, &mut self.refs, path, RefKind::Image, ImageSlot::Reference);
}
}
fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) {
if self.generating || self.settings.prompt.trim().is_empty() {
return;
}
let Some(model) = model else {
return;
};
if !env.signed_in {
env.requests.push(AppRequest::SignIn);
return;
}
let limit = model.prompt_max;
if editor::is_over_limit(&self.settings.prompt, limit) {
env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default()));
return;
}
if self.refs.any_uploading() {
env.toasts.error("Please wait for your references to finish uploading");
return;
}
self.generating = true;
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(self.settings.count.max(1)), ref_image: self.refs.images.first().and_then(|r| r.preview.clone()) };
env.backend.generate(Modality::Image, self.fields(model, false), meta);
}
/// The OmniGen request (`GenerateImageRequest` after `api_fields`). `estimate` swaps
/// still-uploading references for placeholders so the cost reflects them.
fn fields(&self, model: &ModelInfo, estimate: bool) -> Map<String, Value> {
let mut f = Map::new();
f.insert("model".into(), json!(model.id));
f.insert("prompt".into(), json!(self.settings.prompt));
f.insert("image_batch_count".into(), json!(model.valid_batch(self.settings.count)));
if let Some(v) = ModelInfo::resolve(self.settings.aspect_ratio.as_deref(), &model.aspect_ratios, model.aspect_default.as_ref()) {
f.insert("aspect_ratio".into(), json!(v));
}
if !model.resolutions.is_empty() {
if let Some(v) = ModelInfo::resolve(self.settings.resolution.as_deref(), &model.resolutions, model.resolution_default.as_ref()) {
f.insert("resolution".into(), json!(v));
}
}
if !model.qualities.is_empty() {
if let Some(v) = ModelInfo::resolve(self.settings.quality.as_deref(), &model.qualities, model.quality_default.as_ref()) {
f.insert("quality".into(), json!(v));
}
}
if model.image_refs_max > 0 && !self.refs.images.is_empty() {
let tokens: Vec<String> = self.refs.images.iter().enumerate().filter_map(|(i, r)| r.token.clone().or_else(|| estimate.then(|| format!("mf_estimate_{i}")))).collect();
if !tokens.is_empty() {
f.insert("image_media_tokens".into(), json!(tokens));
}
}
if estimate {
f.remove("prompt");
}
f
}
/// Keeps the count valid for the model (`isValidGenerationCount`).
fn reconcile(&mut self, model: &ModelInfo) {
let count = model.valid_batch(self.settings.count);
if count != self.settings.count {
self.settings.count = count;
}
}
pub fn on_enqueued(&mut self, env: &mut Env<'_>, job_tokens: &[String], meta: &EnqueueMeta) {
self.generating = false;
self.feed.add_enqueued(MediaKind::Image, job_tokens, meta, env.catalog);
env.toasts.success("Image generation enqueued!");
}
pub fn on_enqueue_failed(&mut self, env: &mut Env<'_>, message: &str) {
self.generating = false;
env.toasts.error(if message.is_empty() { "Failed to start image generation. Please try again." } else { message });
}
/// Recreate: the original prompt, model, settings and reference images.
pub fn apply_prompt(&mut self, prompt: &Prompt) {
self.settings.prompt = prompt.maybe_positive_prompt.clone().unwrap_or_default();
if let Some(model) = &prompt.maybe_model_type {
self.settings.model = Some(model.clone());
}
if prompt.maybe_aspect_ratio.is_some() {
self.settings.aspect_ratio = prompt.maybe_aspect_ratio.clone();
}
if prompt.maybe_resolution.is_some() {
self.settings.resolution = prompt.maybe_resolution.clone();
}
if let Some(count) = prompt.maybe_batch_count {
self.settings.count = count;
}
self.refs.images = prompt.maybe_context_images.iter().flatten().filter(|c| !matches!(c.semantic.as_str(), "vid_end_frame" | "vid_ref" | "audioref")).map(|c| RefMedia::from_library(RefKind::Image, c.media_token.clone(), c.media_links.thumbnail(256), Some(c.media_links.cdn_url.clone()), 0.0)).collect();
self.box_state.editor.focus_requested = true;
}
}
fn max_images_message(max: usize) -> String {
format!("Max {max} image reference{}", if max == 1 { "" } else { "s" })
}

View File

@ -0,0 +1,541 @@
//! Create Video (`ImageToVideo` + `PromptBoxVideo`): keyframe or omni-reference input, the
//! model's pickers (aspect ratio, resolution, bitrate, format, duration, sound, count), the cost,
//! and the video feed.
use std::path::PathBuf;
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::wire::Prompt;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::models::{self, DEFAULT_PROMPT_MAX, DEFAULT_VIDEO_MODEL, ModelInfo};
use crate::pages::common::{self, AppRequest, CostState, Env};
use crate::pages::toolbar;
use crate::prompt_box::deck::{DeckAction, DeckLimits};
use crate::prompt_box::editor::{self, MentionItem, MentionKind};
use crate::prompt_box::model_selector::model_selector;
use crate::prompt_box::pickers::{self, PickOption, Trigger};
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References};
use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState};
use crate::ui::icons::Icon;
use crate::ui::widgets::Leading;
const PLACEHOLDER: &str = "Describe what you want to happen in the video...";
const PLACEHOLDER_REFERENCE: &str = "Use @Image1, @Video1, @Audio1... to reference uploads in prompt...";
const TEXT_ONLY_BANNER: &str = "This model can't generate from text alone - add a starting frame to animate your prompt.";
/// How the user steers the video: first/last frames, or a mix of reference media.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputMode {
Keyframe,
#[default]
Reference,
}
/// What the page remembers between launches.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoSettings {
pub prompt: String,
pub model: Option<String>,
pub aspect_ratio: Option<String>,
pub resolution: Option<String>,
pub bitrate: Option<String>,
pub output_format: Option<String>,
pub duration: Option<u16>,
pub generate_audio: bool,
pub input_mode: InputMode,
pub count: u16,
}
impl Default for VideoSettings {
fn default() -> Self {
Self { prompt: String::new(), model: None, aspect_ratio: None, resolution: None, bitrate: None, output_format: None, duration: None, generate_audio: true, input_mode: InputMode::Reference, count: 1 }
}
}
#[derive(Default)]
pub struct CreateVideoPage {
pub settings: VideoSettings,
pub refs: References,
pub feed: FeedStore,
pub cost: CostState,
pub generating: bool,
box_state: PromptBoxState,
box_height: f32,
}
impl CreateVideoPage {
pub fn new(settings: VideoSettings) -> Self {
let mut page = Self { settings, box_height: 140.0, ..Default::default() };
page.box_state.editor.focus_requested = true;
page
}
pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) {
let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned();
if let Some(m) = &model {
self.reconcile(m);
let fields = self.fields(m, true);
self.cost.update(ui, env.backend, Modality::Video, &fields);
}
let mut feed = std::mem::take(&mut self.feed);
let mut box_height = self.box_height;
common::create_shell(ui, env, MediaKind::Video, "Create Video", "Choose an image, add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref()));
self.feed = feed;
self.box_height = box_height;
}
fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) {
let mode = self.settings.input_mode;
let reference = mode == InputMode::Reference;
let limits = model.map(|m| deck_limits(m, env.signed_in));
let deck = match (model, mode) {
(Some(m), InputMode::Keyframe) if m.start_frame => DeckMode::Keyframes { show_last: m.end_frame, library: env.signed_in },
(Some(_), InputMode::Reference) => limits.clone().map_or(DeckMode::None, DeckMode::References),
_ => DeckMode::None,
};
let accepts: Vec<RefKind> = match (&deck, model) {
(DeckMode::Keyframes { .. }, _) => vec![RefKind::Image],
(DeckMode::References(l), _) => [(RefKind::Image, l.max_images), (RefKind::Video, l.max_videos), (RefKind::Audio, l.max_audios)].into_iter().filter(|(_, n)| *n > 0).map(|(k, _)| k).collect(),
_ => Vec::new(),
};
let mentions = if reference { self.mentions() } else { Vec::new() };
let needs_start = model.is_some_and(|m| self.needs_start_frame(m));
let banner = model.filter(|m| !m.text_to_video && !self.has_image_input()).map(|_| TEXT_ONLY_BANNER);
let props = PromptBoxProps { id: Id::new("video-prompt-box"), placeholder: if reference { PLACEHOLDER_REFERENCE } else { PLACEHOLDER }, enter_to_generate: env.enter_to_generate, mentions: &mentions, max_length: Some(model.map_or(Some(DEFAULT_PROMPT_MAX), |m| m.prompt_max)), deck, accepts: &accepts, credits: self.cost.credits, generate_enabled: !self.settings.prompt.trim().is_empty() && model.is_some() && !needs_start, generating: self.generating, generate_tooltip: if needs_start { "Add a starting image before generating" } else { "Generate" }, warning: needs_start.then_some("Starting frame required"), banner };
let models = &env.catalog.video_page;
let mut prompt = std::mem::take(&mut self.settings.prompt);
let count = self.settings.count;
let settings = &mut self.settings;
let with_refs = reference && !self.refs.images.is_empty();
let mut picked_model = None;
let mut picked_mode = None;
let mut left = |ui: &mut Ui| {
if let Some(id) = model_selector(ui, Id::new("video-model"), models, model, true) {
picked_model = Some(id);
}
if let Some(m) = model {
picked_mode = video_toolbar(ui, m, settings, with_refs).or(picked_mode);
}
};
let mut picked_count = None;
let mut right = |ui: &mut Ui| {
if let Some(m) = model {
picked_count = toolbar::count(ui, Id::new("video-count"), m, count, "videos");
}
};
let actions = prompt_box::show(ui, &mut prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut left, &mut right);
self.settings.prompt = prompt;
if let Some(id) = picked_model {
self.settings.model = Some(id);
}
if let Some(n) = picked_count {
self.settings.count = n;
}
if let Some(mode) = picked_mode {
switch_mode(&mut self.settings, &mut self.refs, mode);
}
for action in actions {
self.handle(action, env, model, limits.as_ref());
}
}
fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, limits: Option<&DeckLimits>) {
match action {
PromptBoxAction::Generate => self.generate(env, model),
PromptBoxAction::ClearAll => {
self.settings.prompt.clear();
self.refs.clear();
},
PromptBoxAction::Deck(deck) => match deck {
DeckAction::Upload { kinds, slot } => {
let multiple = slot == ImageSlot::Reference;
let paths = common::pick_files(&kinds, multiple);
self.add_files(env, &paths, slot, limits);
},
DeckAction::Library { kind, slot } => {
let max = match slot {
ImageSlot::Reference => limits.map_or(1, |l| free_slots(&self.refs, kind, l)).max(1),
_ => 1,
};
env.requests.push(AppRequest::PickFromLibrary { kind, slot, max, page: MediaKind::Video });
},
DeckAction::Remove(id) => self.refs.remove(id),
DeckAction::ClearAll => {
self.refs.images.clear();
self.refs.videos.clear();
self.refs.audios.clear();
},
DeckAction::ReorderImages { from, to } => {
let item = self.refs.images.remove(from);
self.refs.images.insert(to.min(self.refs.images.len()), item);
},
DeckAction::Preview(id) => {
if let Some(url) = self.refs.find_mut(id).and_then(|r| r.full_url.clone().or_else(|| r.preview.clone())) {
env.requests.push(AppRequest::Preview(url));
}
},
DeckAction::SwapFrames => self.refs.swap_frames(),
},
PromptBoxAction::DroppedFiles(paths) => self.add_files(env, &paths, ImageSlot::Reference, limits),
PromptBoxAction::PastedImage(png) => match self.settings.input_mode {
InputMode::Keyframe => match self.free_keyframe(model) {
Some(slot) => common::upload_png(env, &mut self.refs, png, slot),
None => env.toasts.error(frames_full_message(model)),
},
InputMode::Reference => {
if limits.is_some_and(|l| free_slots(&self.refs, RefKind::Image, l) > 0) {
common::upload_png(env, &mut self.refs, png, ImageSlot::Reference);
} else {
env.toasts.error(max_message(RefKind::Image, limits.map_or(0, |l| l.max_images), None));
}
},
},
}
}
/// Routes picked or dropped files (`handleDroppedFiles`): keyframe mode fills the first frame,
/// then the last; reference mode adds each kind up to its limit.
fn add_files(&mut self, env: &mut Env<'_>, paths: &[PathBuf], slot: ImageSlot, limits: Option<&DeckLimits>) {
let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned();
for path in paths {
let Some(kind) = RefKind::from_path(path) else {
env.toasts.error(format!("{} isn't a supported file", common::file_name(path)));
continue;
};
match (self.settings.input_mode, kind) {
(InputMode::Keyframe, RefKind::Image) => {
let target = if slot == ImageSlot::Reference { self.free_keyframe(model.as_ref()) } else { Some(slot) };
match target {
Some(slot) => common::upload_path(env, &mut self.refs, path, kind, slot),
None => env.toasts.error(frames_full_message(model.as_ref())),
}
},
(InputMode::Keyframe, _) => env.toasts.error(format!("{} references aren't available here", if kind == RefKind::Video { "Video" } else { "Audio" })),
(InputMode::Reference, _) => {
let Some(l) = limits else {
continue;
};
let max = match kind {
RefKind::Image => l.max_images,
RefKind::Video => l.max_videos,
RefKind::Audio => l.max_audios,
};
if max == 0 {
env.toasts.error(format!("{}{} references aren't available here", kind.noun()[..1].to_uppercase(), &kind.noun()[1..]));
} else if free_slots(&self.refs, kind, l) == 0 {
let secs = if kind == RefKind::Video { l.max_video_secs } else { l.max_audio_secs };
env.toasts.error(max_message(kind, max, secs.filter(|_| kind != RefKind::Image)));
} else {
common::upload_path(env, &mut self.refs, path, kind, ImageSlot::Reference);
}
},
}
}
}
/// After an upload lands: enforce the model's total-duration limits.
pub fn check_durations(&mut self, env: &mut Env<'_>, ref_id: u64) {
let Some(model) = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL) else {
return;
};
for (kind, max) in [(RefKind::Video, model.video_refs_max_secs), (RefKind::Audio, model.audio_refs_max_secs)] {
let Some(max) = max else {
continue;
};
if self.refs.list(kind).iter().any(|r| r.id == ref_id) && self.refs.total_secs(kind) > f32::from(max) + 0.5 {
self.refs.remove(ref_id);
env.toasts.error(format!("Total {} duration cannot exceed {max}s", kind.noun()));
}
}
}
fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) {
if self.generating {
return;
}
if self.settings.prompt.trim().is_empty() {
env.toasts.error("Please enter a prompt to generate video");
return;
}
let Some(model) = model else {
env.toasts.error("Please select a model to generate video");
return;
};
if !env.signed_in {
env.requests.push(AppRequest::SignIn);
return;
}
let limit = model.prompt_max;
if editor::is_over_limit(&self.settings.prompt, limit) {
env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default()));
return;
}
if self.needs_start_frame(model) {
env.toasts.error("Please add a starting frame image to generate video");
return;
}
if self.refs.any_uploading() {
env.toasts.error("Please wait for your references to finish uploading");
return;
}
self.generating = true;
let ref_image = self.refs.first_frame.as_ref().or(self.refs.images.first()).and_then(|r| r.preview.clone());
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image };
env.backend.generate(Modality::Video, self.fields(model, false), meta);
}
/// The OmniGen request (`GenerateVideoRequest` after `api_fields`).
fn fields(&self, m: &ModelInfo, estimate: bool) -> Map<String, Value> {
let s = &self.settings;
let mut f = Map::new();
f.insert("model".into(), json!(m.id));
if !estimate {
f.insert("prompt".into(), json!(s.prompt));
}
let resolved = [("aspect_ratio", ModelInfo::resolve(s.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref())), ("resolution", ModelInfo::resolve(s.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref()).filter(|_| !m.resolutions.is_empty())), ("bitrate", ModelInfo::resolve(s.bitrate.as_deref(), &m.bitrates, m.bitrate_default.as_ref()).filter(|_| !m.bitrates.is_empty())), ("output_format", ModelInfo::resolve(s.output_format.as_deref(), &m.output_formats, m.output_format_default.as_ref()).filter(|_| !m.output_formats.is_empty()))];
for (key, value) in resolved {
if let Some(v) = value {
f.insert(key.into(), json!(v));
}
}
let reference = s.input_mode == InputMode::Reference;
if let Some(d) = m.resolve_duration(s.duration, reference && !self.refs.images.is_empty()) {
f.insert("duration_seconds".into(), json!(d));
}
if m.sound_toggle {
f.insert("generate_audio".into(), json!(s.generate_audio));
}
// Only models that list batch sizes take a count (older ones reject the field).
if !m.batch_sizes.is_empty() {
f.insert("video_batch_count".into(), json!(m.valid_batch(s.count)));
}
let token = |r: &RefMedia, i: usize| r.token.clone().or_else(|| estimate.then(|| format!("mf_estimate_{i}")));
if reference {
for (key, list) in [("reference_image_media_tokens", &self.refs.images), ("reference_video_media_tokens", &self.refs.videos), ("reference_audio_media_tokens", &self.refs.audios)] {
let tokens: Vec<String> = list.iter().enumerate().filter_map(|(i, r)| token(r, i)).collect();
if !tokens.is_empty() {
f.insert(key.into(), json!(tokens));
}
}
if estimate {
let millis = |kind| (self.refs.total_secs(kind) * 1000.0) as u32;
f.insert("estimate_only".into(), json!({ "total_input_video_duration_millis": millis(RefKind::Video), "total_input_audio_duration_millis": millis(RefKind::Audio) }));
}
} else {
if let Some(t) = self.refs.first_frame.as_ref().and_then(|r| token(r, 0)) {
f.insert("start_frame_image_media_token".into(), json!(t));
}
if let Some(t) = self.refs.last_frame.as_ref().filter(|_| m.end_frame).and_then(|r| token(r, 1)) {
f.insert("end_frame_image_media_token".into(), json!(t));
}
}
f
}
/// Model-change reconciliation: input mode, end frame and count.
fn reconcile(&mut self, m: &ModelInfo) {
let wanted = match self.settings.input_mode {
InputMode::Reference if !m.supports_reference_mode() => InputMode::Keyframe,
InputMode::Keyframe if !m.start_frame && m.supports_reference_mode() => InputMode::Reference,
mode => mode,
};
if wanted != self.settings.input_mode {
switch_mode(&mut self.settings, &mut self.refs, wanted);
}
if !m.end_frame && self.refs.last_frame.is_some() {
self.refs.last_frame = None;
}
self.settings.count = m.valid_batch(self.settings.count);
}
fn needs_start_frame(&self, m: &ModelInfo) -> bool {
let keyframe = self.settings.input_mode == InputMode::Keyframe;
let required = (keyframe && m.start_frame_required) || (!m.text_to_video && m.start_frame);
required && !self.has_image_input()
}
fn has_image_input(&self) -> bool {
match self.settings.input_mode {
InputMode::Keyframe => self.refs.first_frame.is_some(),
InputMode::Reference => !self.refs.images.is_empty(),
}
}
/// The first empty keyframe slot, if any.
fn free_keyframe(&self, model: Option<&ModelInfo>) -> Option<ImageSlot> {
if self.refs.first_frame.is_none() {
Some(ImageSlot::FirstFrame)
} else if self.refs.last_frame.is_none() && model.is_some_and(|m| m.end_frame) {
Some(ImageSlot::LastFrame)
} else {
None
}
}
/// `@Image1…`, `@Video1…`, `@Audio1…` for the attached references.
fn mentions(&self) -> Vec<MentionItem> {
let mut items = editor::mention_items(MentionKind::Image, self.refs.images.iter().map(|r| r.preview.clone()));
items.extend(editor::mention_items(MentionKind::Video, self.refs.videos.iter().map(|r| r.preview.clone())));
items.extend(editor::mention_items(MentionKind::Audio, self.refs.audios.iter().map(|_| None)));
items
}
/// "Make Video" from an image: it becomes the first frame (or the first reference).
pub fn set_start_image(&mut self, image: RefMedia) {
match self.settings.input_mode {
InputMode::Keyframe => self.refs.first_frame = Some(image),
InputMode::Reference => self.refs.images = vec![image],
}
self.box_state.editor.focus_requested = true;
}
pub fn on_enqueued(&mut self, env: &mut Env<'_>, job_tokens: &[String], meta: &EnqueueMeta) {
self.generating = false;
self.feed.add_enqueued(MediaKind::Video, job_tokens, meta, env.catalog);
env.toasts.success("Video generation enqueued!");
}
pub fn on_enqueue_failed(&mut self, env: &mut Env<'_>, message: &str) {
self.generating = false;
env.toasts.error(if message.is_empty() { "Failed to start video generation. Please try again." } else { message });
}
/// Recreate: the original prompt, model, settings and references.
pub fn apply_prompt(&mut self, prompt: &Prompt) {
let s = &mut self.settings;
s.prompt = prompt.maybe_positive_prompt.clone().unwrap_or_default();
if prompt.maybe_model_type.is_some() {
s.model = prompt.maybe_model_type.clone();
}
if prompt.maybe_aspect_ratio.is_some() {
s.aspect_ratio = prompt.maybe_aspect_ratio.clone();
}
if prompt.maybe_resolution.is_some() {
s.resolution = prompt.maybe_resolution.clone();
}
if prompt.maybe_duration_seconds.is_some() {
s.duration = prompt.maybe_duration_seconds;
}
if let Some(audio) = prompt.maybe_generate_audio {
s.generate_audio = audio;
}
self.refs.clear();
for c in prompt.maybe_context_images.iter().flatten() {
let kind = match c.semantic.as_str() {
"vid_ref" => RefKind::Video,
"audioref" => RefKind::Audio,
_ => RefKind::Image,
};
let item = RefMedia::from_library(kind, c.media_token.clone(), c.media_links.thumbnail(256), Some(c.media_links.cdn_url.clone()), 0.0);
match (c.semantic.as_str(), kind) {
("vid_end_frame", _) => self.refs.last_frame = Some(item),
(_, RefKind::Image) if s.input_mode == InputMode::Keyframe && self.refs.first_frame.is_none() => self.refs.first_frame = Some(item),
(_, kind) => self.refs.list_mut(kind).push(item),
}
}
self.box_state.editor.focus_requested = true;
}
}
/// The video-only pickers: aspect ratio through input mode (left toolbar after the model).
/// Returns a newly picked input mode (applied by the caller, since it moves references).
fn video_toolbar(ui: &mut Ui, m: &ModelInfo, s: &mut VideoSettings, with_refs: bool) -> Option<InputMode> {
let aspect = ModelInfo::resolve(s.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref());
if let Some(v) = toolbar::aspect_ratio(ui, Id::new("video-aspect"), m, aspect.as_deref().unwrap_or("")) {
s.aspect_ratio = Some(v);
}
let resolution = ModelInfo::resolve(s.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref());
if let Some(v) = toolbar::resolution(ui, Id::new("video-resolution"), m, resolution.as_deref().unwrap_or("")) {
s.resolution = Some(v);
}
if !m.bitrates.is_empty() {
let current = ModelInfo::resolve(s.bitrate.as_deref(), &m.bitrates, m.bitrate_default.as_ref()).unwrap_or_default();
let options: Vec<PickOption<String>> = m.bitrates.iter().map(|v| PickOption::new(v.clone(), models::bitrate_label(v))).collect();
if let Some(v) = pickers::option_picker(ui, Id::new("video-bitrate"), Trigger::Pill(Leading::None), "Bitrate", "Bitrate", &current, &options) {
s.bitrate = Some(v);
}
}
if !m.output_formats.is_empty() {
let current = ModelInfo::resolve(s.output_format.as_deref(), &m.output_formats, m.output_format_default.as_ref()).unwrap_or_default();
let options: Vec<PickOption<String>> = m.output_formats.iter().map(|v| PickOption::new(v.clone(), v.to_uppercase())).collect();
if let Some(v) = pickers::option_picker(ui, Id::new("video-format"), Trigger::Pill(Leading::None), "Output Format", "Output format", &current, &options) {
s.output_format = Some(v);
}
}
if let (Some((min, max)), Some(current)) = (m.duration_range(with_refs), m.resolve_duration(s.duration, with_refs)) {
if let Some(v) = pickers::duration_picker(ui, Id::new("video-duration"), u32::from(current), u32::from(min), u32::from(max)) {
s.duration = Some(v as u16);
}
}
if m.sound_toggle && pickers::toggle(ui, Id::new("video-sound"), if s.generate_audio { Icon::Volume } else { Icon::VolumeOff }, "Sound", s.generate_audio, if s.generate_audio { "Sound: ON" } else { "Sound: OFF" }) {
s.generate_audio = !s.generate_audio;
}
if m.start_frame && m.supports_reference_mode() {
let options = [PickOption::new(InputMode::Keyframe, "Keyframe").subtitle("First/Last frame"), PickOption::new(InputMode::Reference, "Omni Reference").subtitle("Multi-media ref")];
return pickers::option_picker(ui, Id::new("video-mode"), Trigger::Pill(Leading::None), "Input Mode", "Input mode", &s.input_mode, &options);
}
None
}
/// Switches input mode, carrying the first image across: keyframe → reference moves the first
/// frame to the front of the deck (and drops the last frame); reference → keyframe takes the
/// deck's first image as the first frame (and drops videos and audio).
fn switch_mode(s: &mut VideoSettings, refs: &mut References, mode: InputMode) {
match mode {
InputMode::Reference => {
if let Some(first) = refs.first_frame.take() {
refs.images.insert(0, first);
}
refs.last_frame = None;
},
InputMode::Keyframe => {
if refs.first_frame.is_none() && !refs.images.is_empty() {
refs.first_frame = Some(refs.images.remove(0));
}
refs.videos.clear();
refs.audios.clear();
},
}
s.input_mode = mode;
}
fn deck_limits(m: &ModelInfo, library: bool) -> DeckLimits {
DeckLimits { max_images: m.image_refs_max, max_videos: m.video_refs_max, max_video_secs: m.video_refs_max_secs.map(f32::from), max_audios: m.audio_refs_max, max_audio_secs: m.audio_refs_max_secs.map(f32::from), library }
}
fn free_slots(refs: &References, kind: RefKind, l: &DeckLimits) -> usize {
let max = match kind {
RefKind::Image => l.max_images,
RefKind::Video => l.max_videos,
RefKind::Audio => l.max_audios,
};
max.saturating_sub(refs.list(kind).len())
}
fn max_message(kind: RefKind, max: usize, secs: Option<f32>) -> String {
let noun = match kind {
RefKind::Image => "image reference",
RefKind::Video => "video",
RefKind::Audio => "audio clip",
};
let plural = if max == 1 { "" } else { "s" };
match secs {
Some(s) => format!("Max {max} {noun}{plural} / {}s total", s.round() as i64),
None => format!("Max {max} {noun}{plural}"),
}
}
fn frames_full_message(model: Option<&ModelInfo>) -> &'static str {
if model.is_some_and(|m| m.end_frame) {
"First and last frames are already set"
} else {
"The first frame is already set"
}
}

View File

@ -0,0 +1,5 @@
pub mod common;
pub mod create_image;
pub mod create_video;
pub mod other;
pub mod toolbar;

View File

@ -0,0 +1,129 @@
//! The pages around the create tools: Home, the Library, and a placeholder for tools that are
//! still web-only.
use egui::{Color32, Id, Rect, Sense, Ui, pos2, vec2};
use crate::feed::grid::{self, FeedAction, FeedView};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::pages::common::{AppRequest, Env, FeedKey};
use crate::shell::{Page, WEB_APP_URL};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
/// Home: what ArtCraft does and where to start. Returns a page to open.
pub fn home(ui: &mut Ui, signed_in: bool, requests: &mut Vec<AppRequest>) -> Option<Page> {
let mut open = None;
let area = ui.max_rect();
let width = (area.width() - 64.0).min(960.0);
let mut col = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(area.center().x - width / 2.0, area.top() + 56.0), vec2(width, area.height() - 56.0))));
let ui = &mut col;
widgets::hud(ui, "01 / ArtCraft", theme::FAINT);
ui.add_space(10.0);
ui.label(widgets::heading("Capable tools for artists.", 52.0));
ui.add_space(10.0);
ui.label(egui::RichText::new("Generate images and videos with the best models, steer them with references and keyframes, and keep everything in your library.").size(17.0).color(theme::MUTED));
ui.add_space(28.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
let card_w = (width - 16.0) / 2.0;
for (page, icon, title, text) in [(Page::CreateImage, Icon::Image, "Create Image", "Describe anything. See it in seconds."), (Page::CreateVideo, Icon::Video, "Create Video", "Describe a scene. See it in motion.")] {
if card(ui, card_w, icon, title, text) {
open = Some(page);
}
}
});
if !signed_in {
ui.add_space(24.0);
if widgets::button(ui, Some(Icon::LogIn), "Sign in to start", widgets::Kind::Primary, 0.0, 44.0).clicked() {
requests.push(AppRequest::SignIn);
}
}
open
}
fn card(ui: &mut Ui, width: f32, icon: Icon, title: &str, text: &str) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(width, 180.0), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
p.rect(rect, 0.0, if hovered { Color32::from_rgba_unmultiplied(242, 241, 238, 8) } else { Color32::from_rgba_unmultiplied(242, 241, 238, 4) }, egui::Stroke::new(1.0, if hovered { theme::LINE_STRONG } else { theme::LINE }), egui::StrokeKind::Inside);
widgets::corner_marks(p, rect);
icons::paint(p, Rect::from_min_size(rect.min + vec2(24.0, 24.0), vec2(26.0, 26.0)), icon, theme::INK);
let body = Rect::from_min_max(pos2(rect.left() + 24.0, rect.top() + 76.0), pos2(rect.right() - 24.0, rect.bottom() - 16.0));
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)));
inner.label(widgets::heading(title, 26.0));
inner.add_space(4.0);
inner.label(egui::RichText::new(text).size(14.0).color(theme::MUTED));
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.right() - 28.0, rect.top() + 36.0), vec2(16.0, 16.0)), Icon::ChevronRight, if hovered { theme::INK } else { theme::FAINT });
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// The Library: everything generated, newest first.
pub fn library(ui: &mut Ui, env: &mut Env<'_>, feed: &mut FeedStore) {
let area = ui.max_rect();
if !env.signed_in {
placeholder(ui, area, "Library", "Sign in to see everything you've generated.", None);
return;
}
if !feed.library_started() {
env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::Library));
}
if !feed.has_content() && !feed.loading {
placeholder(ui, area, "Library", "Your generations will appear here.", None);
return;
}
let view = FeedView { id: Id::new("library-feed"), mode: env.view_mode, pending: &[], failed: &[], items: &feed.items, has_more: feed.has_more, loading: feed.loading, selecting: feed.selecting, selected: &feed.selected, last_viewed: feed.last_viewed.as_deref(), prompts: env.prompts, make_video: true, bottom_padding: 24.0 };
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area.shrink2(vec2(12.0, 0.0)).with_min_y(area.top() + 2.0)));
for action in grid::show(&mut child, &view, env.cache, env.ratios, env.catalog) {
match action {
FeedAction::Open(token) => {
let kind = feed.find(&token).map_or(MediaKind::Image, |i| i.kind);
feed.last_viewed = Some(token.clone());
env.requests.push(AppRequest::Open { kind, token });
},
FeedAction::ToggleSelect(token) => {
if !feed.selected.remove(&token) {
feed.selected.insert(token);
}
},
FeedAction::LoadMore => env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::Library)),
FeedAction::Recreate(token) => {
let kind = feed.find(&token).map_or(MediaKind::Image, |i| i.kind);
env.requests.push(AppRequest::Recreate { kind, token });
},
FeedAction::MakeVideo(token) => env.requests.push(AppRequest::MakeVideo { token }),
FeedAction::Share(token) => env.requests.push(AppRequest::Share(token)),
FeedAction::Download(token) => env.requests.push(AppRequest::Download(vec![token])),
FeedAction::CopyPrompt(text) => env.requests.push(AppRequest::CopyText(text)),
FeedAction::DismissFailed(_) => {},
}
}
if feed.selecting {
crate::pages::common::selection_bar(ui, area, 24.0, feed, MediaKind::Image, env.requests);
}
}
/// A tool that hasn't come to the native app yet.
pub fn coming_soon(ui: &mut Ui, page: Page) {
let area = ui.max_rect();
placeholder(ui, area, page.label(), "This tool is coming to the native app. You can use it in the ArtCraft web app in the meantime.", Some(format!("{WEB_APP_URL}{}", page.web_path())));
}
/// The bordered, corner-marked splash used for empty and placeholder pages.
fn placeholder(ui: &mut Ui, area: Rect, title: &str, text: &str, web_url: Option<String>) {
let width = (area.width() - 64.0).min(768.0);
let section = Rect::from_center_size(area.center(), vec2(width, if web_url.is_some() { 240.0 } else { 180.0 }));
ui.painter().rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside);
widgets::corner_marks(ui.painter(), section);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 44.0))));
child.label(widgets::heading(title, 44.0));
child.add_space(10.0);
child.label(egui::RichText::new(text).size(15.0).color(theme::MUTED));
if let Some(url) = web_url {
child.add_space(20.0);
if widgets::button(&mut child, Some(Icon::ExternalLink), "Open in web app", widgets::Kind::Primary, 0.0, 40.0).clicked() {
child.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
}

View File

@ -0,0 +1,57 @@
//! Toolbar pickers both create pages use, each shown only when the model offers a choice.
use egui::{Id, Ui};
use crate::models::{self, ModelInfo};
use crate::prompt_box::pickers::{self, OptionLead, PickOption, Trigger};
use crate::ui::icons::Icon;
use crate::ui::widgets::Leading;
/// The aspect ratio picker (`AspectRatioPicker`). `current` is the effective value.
pub fn aspect_ratio(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option<String> {
if model.aspect_ratios.is_empty() {
return None;
}
let options: Vec<PickOption<String>> = model.aspect_ratios.iter().map(|v| PickOption::new(v.clone(), models::aspect_label(v)).lead(aspect_lead(v))).collect();
let lead = match models::aspect_proportion(current) {
Some(ratio) => Leading::Aspect(Some(ratio)),
None => Leading::Icon(Icon::Wand),
};
pickers::option_picker(ui, id, Trigger::Pill(lead), "Aspect Ratio", "Aspect Ratio", &current.to_owned(), &options)
}
fn aspect_lead(value: &str) -> OptionLead {
match models::aspect_proportion(value) {
Some(ratio) => OptionLead::Aspect(Some(ratio)),
None => OptionLead::Icon(Icon::Wand),
}
}
/// The resolution picker (`ResolutionPicker`), its pill captioned SD or HD.
pub fn resolution(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option<String> {
if model.resolutions.is_empty() {
return None;
}
let options: Vec<PickOption<String>> = model.resolutions.iter().map(|v| PickOption::new(v.clone(), models::resolution_label(v))).collect();
let caption = if models::resolution_is_hd(current) { "HD" } else { "SD" };
pickers::option_picker(ui, id, Trigger::Pill(Leading::Caption(caption)), "Resolution", "Resolution", &current.to_owned(), &options)
}
/// The quality picker (`QualityPicker`).
pub fn quality(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option<String> {
if model.qualities.is_empty() {
return None;
}
let options: Vec<PickOption<String>> = model.qualities.iter().map(|v| PickOption::new(v.clone(), models::quality_label(v))).collect();
pickers::option_picker(ui, id, Trigger::Pill(Leading::Icon(Icon::Gem)), "Quality", "Quality", &current.to_owned(), &options)
}
/// The number of generations (`GenerationCountPicker`): a quiet copy-icon button.
pub fn count(ui: &mut Ui, id: Id, model: &ModelInfo, current: u16, noun: &str) -> Option<u16> {
let options: Vec<PickOption<u16>> = model.batch_options().into_iter().map(|n| PickOption::new(n, n.to_string())).collect();
if options.len() <= 1 {
ui.add_enabled_ui(false, |ui| crate::ui::widgets::ghost(ui, Icon::Copy, Some(&current.to_string()), "Number of generations", false));
return None;
}
pickers::option_picker(ui, id, Trigger::Ghost(Icon::Copy), &format!("No. of {noun}"), "Number of generations", &current, &options)
}

View File

@ -0,0 +1,529 @@
//! The reference widgets left of the prompt: the fanned reference deck (images, videos, audio)
//! and the tilted first/last keyframe slots.
use egui::emath::Rot2;
use egui::load::SizedTexture;
use egui::{Align2, Color32, CornerRadius, FontId, Id, Order, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, Vec2, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, RefStatus, References};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
/// `w-14`.
const CARD: f32 = 56.0;
/// Rotations of the collapsed fan's cards (`FAN_TRANSFORMS`), with their x offsets.
const FAN: [(f32, f32); 3] = [(-8.0, 0.0), (2.0, 12.0), (9.0, 24.0)];
const OPEN_DELAY: f64 = 0.17;
const CLOSE_DELAY: f64 = 0.15;
const MENU_DELAY: f64 = 0.1;
/// Something the user asked the deck to do; the prompt box carries it out.
#[derive(Clone, Debug, PartialEq)]
pub enum DeckAction {
/// Open the file picker for these kinds into `slot` (images only use the slot).
Upload {
kinds: Vec<RefKind>,
slot: ImageSlot,
},
/// Open the library picker for `kind` into `slot`.
Library {
kind: RefKind,
slot: ImageSlot,
},
Remove(u64),
ClearAll,
ReorderImages {
from: usize,
to: usize,
},
Preview(u64),
SwapFrames,
}
/// What the deck may offer, from the model's limits.
#[derive(Clone, Debug)]
pub struct DeckLimits {
pub max_images: usize,
pub max_videos: usize,
pub max_video_secs: Option<f32>,
pub max_audios: usize,
pub max_audio_secs: Option<f32>,
/// Whether "From library" is offered (always, once signed in).
pub library: bool,
}
impl DeckLimits {
fn kinds(&self) -> Vec<RefKind> {
[(RefKind::Image, self.max_images), (RefKind::Video, self.max_videos), (RefKind::Audio, self.max_audios)].into_iter().filter(|(_, max)| *max > 0).map(|(k, _)| k).collect()
}
fn max(&self, kind: RefKind) -> usize {
match kind {
RefKind::Image => self.max_images,
RefKind::Video => self.max_videos,
RefKind::Audio => self.max_audios,
}
}
fn max_secs(&self, kind: RefKind) -> Option<f32> {
match kind {
RefKind::Image => None,
RefKind::Video => self.max_video_secs,
RefKind::Audio => self.max_audio_secs,
}
}
}
/// The fanned reference deck. Returns the user's actions this frame.
pub fn reference_deck(ui: &mut Ui, id: Id, refs: &References, limits: &DeckLimits, cache: &mut MediaCache, always_expanded: bool) -> Vec<DeckAction> {
let mut actions = Vec::new();
let items: Vec<&RefMedia> = refs.images.iter().chain(&refs.videos).chain(&refs.audios).collect();
let open_kinds: Vec<RefKind> = limits.kinds().into_iter().filter(|k| refs.list(*k).len() < limits.max(*k)).collect();
if items.is_empty() && open_kinds.is_empty() {
return actions;
}
let menu = add_menu_entries(refs, limits, &open_kinds, ImageSlot::Reference);
if always_expanded {
expanded_panel(ui, id, refs, &items, &open_kinds, &menu, cache, &mut actions);
return actions;
}
if items.is_empty() {
let resp = add_tile(ui, id.with("empty"), "Reference", -6.0);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: open_kinds.clone(), slot: ImageSlot::Reference });
}
hover_menu(ui, id.with("empty-menu"), &resp, &menu, &mut actions);
return actions;
}
// Collapsed fan: footprint tracks the card count plus the + button's overhang.
let shown = items.len().min(3);
let stack_w = (shown - 1) as f32 * 12.0 + CARD;
let (rect, _) = ui.allocate_exact_size(vec2(stack_w + 12.0, CARD), Sense::hover());
let stack = Rect::from_min_size(rect.min, vec2(stack_w, CARD));
let expanded = hover_latch(ui, id.with("expand"), stack, OPEN_DELAY);
let painter = ui.painter().clone();
if !expanded.open {
for (i, item) in items.iter().take(3).enumerate().rev() {
let (deg, dx) = FAN[i];
let card = Rect::from_min_size(stack.min + vec2(dx, 0.0), vec2(CARD, CARD));
paint_card(ui, &painter, card, deg, item, cache, false);
}
if items.len() > 3 {
let badge_text = items.len().to_string();
let badge = Rect::from_center_size(pos2(stack.right() + 2.0, stack.top() + 2.0), vec2(8.0 + 7.0 * badge_text.len() as f32, 18.0));
painter.rect_filled(badge, CornerRadius::same(9), theme::ACCENT);
painter.text(badge.center(), Align2::CENTER_CENTER, badge_text, FontId::new(10.0, theme::semibold()), Color32::WHITE);
}
if !open_kinds.is_empty() {
let plus = Rect::from_min_size(pos2(stack.right() - 12.0, stack.bottom() - 20.0), vec2(24.0, 24.0));
let resp = ui.interact(plus, id.with("plus"), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let hovered = resp.hovered();
painter.circle(plus.center(), if hovered { 13.0 } else { 12.0 }, widgets::blend(theme::CONTROLS, if hovered { theme::WASH_HOVER } else { Color32::TRANSPARENT }), Stroke::new(1.0, theme::LINE));
icons::paint(&painter, plus.shrink(6.0), Icon::Plus, theme::INK);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: open_kinds.clone(), slot: ImageSlot::Reference });
}
hover_menu(ui, id.with("plus-menu"), &resp, &menu, &mut actions);
}
}
if expanded.open {
// The panel overlays the deck from its bottom-left corner, growing up and right.
let area = egui::Area::new(id.with("panel")).order(Order::Foreground).pivot(Align2::LEFT_BOTTOM).fixed_pos(stack.left_bottom()).show(ui.ctx(), |ui| {
widgets::popover_frame().inner_margin(8).show(ui, |ui| {
ui.set_max_width(ui.ctx().content_rect().width() * 0.5);
expanded_panel(ui, id, refs, &items, &open_kinds, &menu, cache, &mut actions);
});
});
expanded.keep_open_while(ui, area.response.rect.union(stack));
}
actions
}
/// The first and last keyframe slots (the last only when `show_last`).
pub fn keyframe_cards(ui: &mut Ui, id: Id, refs: &References, show_last: bool, library: bool, cache: &mut MediaCache) -> Vec<DeckAction> {
let mut actions = Vec::new();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
slot_card(ui, id.with("first"), refs.first_frame.as_ref(), "First frame", if show_last { -6.0 } else { 0.0 }, ImageSlot::FirstFrame, library, cache, &mut actions);
if show_last {
let both = refs.first_frame.is_some() && refs.last_frame.is_some();
let (rect, _) = ui.allocate_exact_size(vec2(8.0, CARD), Sense::hover());
let swap = Rect::from_center_size(rect.center() - vec2(0.0, 6.0), vec2(20.0, 20.0));
let resp = ui.interact(swap, id.with("swap"), if both { Sense::click() } else { Sense::hover() });
let hovered = resp.hovered() && both;
let p = ui.painter();
p.circle(
swap.center(),
if hovered { 11.0 } else { 10.0 },
Color32::from_black_alpha(if hovered {
204
} else if both {
153
} else {
102
}),
Stroke::new(1.0, theme::fade(Color32::WHITE, if both { 0.2 } else { 0.1 })),
);
icons::paint(p, swap.shrink(5.0), Icon::ArrowLeftRight, theme::fade(Color32::WHITE, if both { 1.0 } else { 0.5 }));
if both && resp.on_hover_text("Swap frames").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
actions.push(DeckAction::SwapFrames);
}
slot_card(ui, id.with("last"), refs.last_frame.as_ref(), "Last frame", 6.0, ImageSlot::LastFrame, library, cache, &mut actions);
}
});
actions
}
/// One labelled keyframe slot: a dashed add card, or the frame once set.
#[allow(clippy::too_many_arguments)]
fn slot_card(ui: &mut Ui, id: Id, item: Option<&RefMedia>, label: &str, tilt: f32, slot: ImageSlot, library: bool, cache: &mut MediaCache, actions: &mut Vec<DeckAction>) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
match item {
Some(item) => {
let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click());
let hovered = resp.hovered();
let painter = ui.painter().clone();
paint_card(ui, &painter, rect, if hovered { 0.0 } else { tilt }, item, cache, hovered);
if item.status == RefStatus::Ready {
if remove_button(ui, id.with("x"), rect, hovered) {
actions.push(DeckAction::Remove(item.id));
} else if resp.on_hover_cursor(egui::CursorIcon::ZoomIn).clicked() {
actions.push(DeckAction::Preview(item.id));
}
}
let (label_rect, _) = ui.allocate_exact_size(vec2(CARD, 10.0), Sense::hover());
ui.painter().text(label_rect.center(), Align2::CENTER_CENTER, label, FontId::new(9.0, theme::medium()), theme::MUTED);
},
None => {
let resp = add_tile(ui, id, label, tilt);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: vec![RefKind::Image], slot });
}
{
let menu = vec![MenuGroup { kind: RefKind::Image, hint: None, slot, library }];
hover_menu(ui, id.with("menu"), &resp, &menu, actions);
}
},
}
});
}
/// The expanded deck: a header with "Clear all", the cards (reorderable images) and a + tile.
#[allow(clippy::too_many_arguments)]
fn expanded_panel(ui: &mut Ui, id: Id, refs: &References, items: &[&RefMedia], open_kinds: &[RefKind], menu: &[MenuGroup], cache: &mut MediaCache, actions: &mut Vec<DeckAction>) {
if items.len() > 1 {
ui.horizontal(|ui| {
let n = items.len();
ui.label(egui::RichText::new(format!("{n} reference{}", if n == 1 { "" } else { "s" })).size(11.0).family(theme::medium()).color(theme::MUTED));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let resp = ui.add(egui::Button::new(egui::RichText::new("Clear all").size(11.0)).frame(false));
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
actions.push(DeckAction::ClearAll);
}
});
});
}
let drag_id = id.with("dragging");
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(drag_id));
let mut image_rects = Vec::with_capacity(refs.images.len());
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
for item in items {
let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click_and_drag());
let hovered = resp.hovered();
let painter = ui.painter().clone();
let image_index = refs.images.iter().position(|r| r.id == item.id);
if let Some(i) = image_index {
image_rects.push((i, rect));
if resp.drag_started() && refs.images.len() > 1 {
dragging = Some(i);
}
}
paint_card(ui, &painter, rect, 0.0, item, cache, hovered && dragging.is_none());
if dragging.is_some() && dragging == image_index {
painter.rect_filled(rect, theme::RADIUS, Color32::from_black_alpha(128));
}
if item.status == RefStatus::Ready && dragging.is_none() {
if remove_button(ui, id.with(("x", item.id)), rect, hovered) {
actions.push(DeckAction::Remove(item.id));
} else if resp.clicked() {
actions.push(DeckAction::Preview(item.id));
}
}
}
if !open_kinds.is_empty() {
let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click());
paint_dashed_tile(ui.painter(), rect, 0.0, resp.hovered(), None);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: open_kinds.to_vec(), slot: ImageSlot::Reference });
}
hover_menu(ui, id.with("panel-menu"), &resp, menu, actions);
}
});
// Drag to reorder images: drop onto another image's card.
if let Some(from) = dragging {
let released = ui.input(|i| i.pointer.any_released());
if released {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if let Some((to, _)) = image_rects.iter().find(|(_, r)| r.contains(pos)) {
if *to != from {
actions.push(DeckAction::ReorderImages { from, to: *to });
}
}
}
dragging = None;
} else {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
ui.data_mut(|d| match dragging {
Some(i) => {
d.insert_temp(drag_id, i);
},
None => d.remove::<usize>(drag_id),
});
}
/// A 56 px card: the thumbnail (cover-fit), a kind badge for video/audio, a duration strip, and a
/// spinner while uploading. `tilt` is in degrees.
fn paint_card(ui: &Ui, painter: &egui::Painter, rect: Rect, tilt: f32, item: &RefMedia, cache: &mut MediaCache, hovered: bool) {
let rot = Rot2::from_angle(tilt.to_radians());
let corners = rotated_corners(rect, rot);
painter.add(Shape::convex_polygon(corners.to_vec(), theme::fade(theme::CONTROLS, 0.95), Stroke::NONE));
let texture = item.preview.as_deref().and_then(|key| match cache.get(ui.ctx(), key) {
Lookup::Ready(t) => Some(t),
_ => None,
});
match texture {
Some(texture) => {
let uv = cover_uv(texture.size_vec2());
let image = egui::Image::from_texture(SizedTexture::from_handle(&texture)).uv(uv).rotate(rot.angle(), Vec2::splat(0.5));
let image = if item.status == RefStatus::Uploading { image.tint(Color32::from_gray(140)) } else { image };
image.paint_at(ui, rect.shrink(2.0));
},
None => {
let icon = match item.kind {
RefKind::Image => Icon::Image,
RefKind::Video => Icon::Video,
RefKind::Audio => Icon::Music,
};
icons::paint(painter, Rect::from_center_size(rect.center(), vec2(20.0, 20.0)), icon, theme::MUTED);
},
}
if item.kind != RefKind::Image && tilt == 0.0 {
let badge = Rect::from_min_size(rect.min + vec2(3.0, 3.0), vec2(16.0, 16.0));
painter.rect_filled(badge, 0.0, Color32::from_black_alpha(153));
icons::paint(painter, badge.shrink(3.0), if item.kind == RefKind::Video { Icon::Video } else { Icon::Music }, Color32::WHITE);
if item.duration_secs > 0.0 {
let strip = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 14.0), rect.max);
painter.rect_filled(strip, 0.0, Color32::from_black_alpha(178));
painter.text(strip.center(), Align2::CENTER_CENTER, format!("{}s", item.duration_secs.round() as i64), FontId::new(10.0, theme::semibold()), Color32::WHITE);
}
}
if item.status == RefStatus::Uploading {
icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(24.0, 24.0)), Color32::WHITE);
}
let border = theme::fade(Color32::WHITE, if hovered { 0.8 } else { 0.3 });
painter.add(Shape::closed_line(corners.to_vec(), Stroke::new(2.0, border)));
}
/// The hover-revealed round × in a card's top-right corner. Returns whether it was clicked.
fn remove_button(ui: &mut Ui, id: Id, card: Rect, card_hovered: bool) -> bool {
let rect = Rect::from_min_size(pos2(card.right() - 22.0, card.top() + 2.0), vec2(20.0, 20.0));
let resp = ui.interact(rect, id, Sense::click());
if !(card_hovered || resp.hovered()) {
return false;
}
let fill = if resp.hovered() { theme::fade(theme::DANGER, 0.7) } else { Color32::from_black_alpha(128) };
ui.painter().circle_filled(rect.center(), 10.0, fill);
icons::paint(ui.painter(), rect.shrink(5.0), Icon::X, Color32::WHITE);
resp.on_hover_text("Remove").on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// The dashed "+" card that adds a reference (straightens and grows on hover).
fn add_tile(ui: &mut Ui, id: Id, label: &str, tilt: f32) -> Response {
let (rect, _) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::hover());
let resp = ui.interact(rect, id, Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let hovered = resp.hovered();
paint_dashed_tile(ui.painter(), if hovered { rect.expand(1.4) } else { rect }, if hovered { 0.0 } else { tilt }, hovered, Some(label));
resp
}
fn paint_dashed_tile(painter: &egui::Painter, rect: Rect, tilt: f32, hovered: bool, label: Option<&str>) {
let rot = Rot2::from_angle(tilt.to_radians());
let corners = rotated_corners(rect, rot);
let fill = theme::fade(theme::CONTROLS, if hovered { 0.6 } else { 0.4 });
painter.add(Shape::convex_polygon(corners.to_vec(), fill, Stroke::NONE));
let mut path = corners.to_vec();
path.push(corners[0]);
painter.extend(Shape::dashed_line(&path, Stroke::new(2.0, theme::LINE_DASHED), 4.0, 3.0));
let c = rect.center();
match label {
Some(label) => {
icons::paint(painter, Rect::from_center_size(c - vec2(0.0, 6.0), vec2(18.0, 18.0)), Icon::Plus, theme::fade(theme::INK, 0.8));
painter.text(c + vec2(0.0, 12.0), Align2::CENTER_CENTER, label, FontId::new(8.5, theme::medium()), theme::fade(theme::INK, 0.7));
},
None => icons::paint(painter, Rect::from_center_size(c, vec2(20.0, 20.0)), Icon::Plus, theme::fade(theme::INK, 0.8)),
}
}
fn rotated_corners(rect: Rect, rot: Rot2) -> [Pos2; 4] {
let c = rect.center();
[rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|p| c + rot * (p - c))
}
/// The UV rect that crops a texture of `size` to a centred square (`object-cover`).
pub fn cover_uv(size: Vec2) -> Rect {
if size.x <= 0.0 || size.y <= 0.0 {
return Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0));
}
if size.x > size.y {
let f = size.y / size.x;
Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0))
} else {
let f = size.x / size.y;
Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0))
}
}
/// One kind's group in an add menu: its "Upload" / "From library" rows and a usage hint.
struct MenuGroup {
kind: RefKind,
hint: Option<String>,
slot: ImageSlot,
library: bool,
}
fn add_menu_entries(refs: &References, limits: &DeckLimits, open_kinds: &[RefKind], slot: ImageSlot) -> Vec<MenuGroup> {
open_kinds
.iter()
.map(|&kind| {
let count = format!("{}/{}", refs.list(kind).len(), limits.max(kind));
let hint = match limits.max_secs(kind) {
Some(max) => format!("{count} \u{b7} {}/{}s", refs.total_secs(kind).round() as i64, max.round() as i64),
None => count,
};
MenuGroup { kind, hint: Some(hint), slot, library: limits.library }
})
.collect()
}
/// The add menu that appears above a trigger after a short hover (the webapp's `DeckAddMenu`).
fn hover_menu(ui: &Ui, id: Id, trigger: &Response, groups: &[MenuGroup], actions: &mut Vec<DeckAction>) {
// A lone "Upload" is what clicking does already: no menu (the webapp's `enabledActions > 1`).
let entries: usize = groups.iter().map(|g| 1 + usize::from(g.library)).sum();
if entries <= 1 {
return;
}
let latch = hover_latch(ui, id, trigger.rect, MENU_DELAY);
if !latch.open {
return;
}
let show_groups = groups.len() > 1;
let area = egui::Area::new(id.with("area")).order(Order::Tooltip).pivot(Align2::CENTER_BOTTOM).fixed_pos(trigger.rect.center_top() - vec2(0.0, 2.0)).show(ui.ctx(), |ui| {
widgets::popover_frame().inner_margin(6).show(ui, |ui| {
ui.set_width(192.0);
ui.spacing_mut().item_spacing.y = 0.0;
for group in groups {
if show_groups {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(group.kind.noun().to_uppercase()).size(10.0).family(theme::semibold()).color(theme::FAINT));
if let Some(hint) = &group.hint {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(hint).size(10.0).family(theme::medium()).color(theme::FAINT));
});
}
});
}
if menu_entry(ui, Icon::Upload, "Upload") {
actions.push(DeckAction::Upload { kinds: vec![group.kind], slot: group.slot });
latch.close(ui);
}
if group.library && menu_entry(ui, Icon::Library, if group.slot == ImageSlot::Reference { "From library" } else { "Pick from library" }) {
actions.push(DeckAction::Library { kind: group.kind, slot: group.slot });
latch.close(ui);
}
}
});
});
latch.keep_open_while(ui, area.response.rect.union(trigger.rect));
}
fn menu_entry(ui: &mut Ui, icon: Icon, label: &str) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, theme::RADIUS, theme::WASH_HOVER);
}
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 17.0, rect.center().y), vec2(14.0, 14.0)), icon, theme::fade(theme::INK, 0.6));
p.text(pos2(rect.left() + 34.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::new(13.0, theme::medium()), theme::INK);
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// Open-on-hover with a delay, staying open while the pointer is over the trigger or what it
/// opened (plus a grace period), like the webapp's hover menus.
struct HoverLatch {
id: Id,
open: bool,
}
#[derive(Clone, Copy, Default)]
struct LatchState {
hover_since: Option<f64>,
left_at: Option<f64>,
open: bool,
}
fn hover_latch(ui: &Ui, id: Id, trigger: Rect, delay: f64) -> HoverLatch {
let now = ui.input(|i| i.time);
let pointer = ui.input(|i| i.pointer.hover_pos());
let over = pointer.is_some_and(|p| trigger.contains(p));
let mut st: LatchState = ui.data(|d| d.get_temp(id).unwrap_or_default());
if over {
st.left_at = None;
let since = *st.hover_since.get_or_insert(now);
if !st.open {
if now - since >= delay {
st.open = true;
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(delay - (now - since)));
}
}
} else {
st.hover_since = None;
}
ui.data_mut(|d| d.insert_temp(id, st));
HoverLatch { id, open: st.open }
}
impl HoverLatch {
/// Keeps the latch open while the pointer is inside `region`; closes it `CLOSE_DELAY` after
/// the pointer leaves.
fn keep_open_while(&self, ui: &Ui, region: Rect) {
let now = ui.input(|i| i.time);
let inside = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| region.expand(4.0).contains(p));
let mut st: LatchState = ui.data(|d| d.get_temp(self.id).unwrap_or_default());
if inside {
st.left_at = None;
} else {
let left = *st.left_at.get_or_insert(now);
if now - left >= CLOSE_DELAY {
st = LatchState::default();
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(CLOSE_DELAY));
}
}
ui.data_mut(|d| d.insert_temp(self.id, st));
}
fn close(&self, ui: &Ui) {
ui.data_mut(|d| d.insert_temp(self.id, LatchState::default()));
}
}

View File

@ -0,0 +1,414 @@
//! The prompt text field: auto-grows to ~4 lines then scrolls, expands or resizes on demand,
//! highlights `@Image1`-style mentions and offers them in an autocomplete dropdown, and shows a
//! length counter when the model has a limit.
use egui::text::{CCursor, CCursorRange, LayoutJob};
use egui::{Align2, Color32, FontFamily, FontId, Id, Key, KeyboardShortcut, Modifiers, Order, Rect, Sense, Stroke, TextBuffer, TextFormat, Ui, pos2, vec2};
use crate::theme;
use crate::ui::icons::{self, Icon};
/// `text-md`.
const FONT_SIZE: f32 = 16.0;
/// `min-h-[2.5em]`.
const MIN_HEIGHT: f32 = FONT_SIZE * 2.5;
/// `max-h-[5.5em]` while collapsed.
const COLLAPSED_MAX_HEIGHT: f32 = FONT_SIZE * 5.5;
/// `max-h-[500px]`.
const MAX_HEIGHT: f32 = 500.0;
/// `pr-8`: room for the fullscreen button.
const RIGHT_GUTTER: f32 = 32.0;
const IMAGE_COLORS: [Color32; 5] = [Color32::from_rgb(96, 165, 250), Color32::from_rgb(251, 146, 60), Color32::from_rgb(167, 139, 250), Color32::from_rgb(52, 211, 153), Color32::from_rgb(251, 113, 133)];
const VIDEO_COLORS: [Color32; 3] = [Color32::from_rgb(250, 204, 21), Color32::from_rgb(245, 158, 11), Color32::from_rgb(74, 222, 128)];
const AUDIO_COLORS: [Color32; 2] = [Color32::from_rgb(192, 132, 252), Color32::from_rgb(232, 121, 249)];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MentionKind {
Image,
Video,
Audio,
}
/// Something the prompt can reference by name, e.g. `@Image2`.
#[derive(Clone, Debug)]
pub struct MentionItem {
pub label: String,
pub kind: MentionKind,
/// The 1-based index within its kind (picks the colour).
pub index: usize,
/// Media cache key of a thumbnail, if there is one.
pub preview: Option<String>,
}
impl MentionItem {
pub fn color(&self) -> Color32 {
let i = self.index.saturating_sub(1);
match self.kind {
MentionKind::Image => IMAGE_COLORS[i % IMAGE_COLORS.len()],
MentionKind::Video => VIDEO_COLORS[i % VIDEO_COLORS.len()],
MentionKind::Audio => AUDIO_COLORS[i % AUDIO_COLORS.len()],
}
}
}
/// Per-editor UI state that outlives a frame.
#[derive(Default)]
pub struct EditorState {
/// The chevron's "expanded" mode: a tall, fixed-height field.
pub expanded: bool,
/// A height the user dragged the resize grip to.
pub manual_height: Option<f32>,
mention: Option<MentionQuery>,
/// Focus the field on the next frame (autofocus, after inserting a mention, after closing
/// fullscreen).
pub focus_requested: bool,
}
struct MentionQuery {
/// Char index of the `@`.
anchor: usize,
/// What's typed after the `@` (without it).
filter: String,
selected: usize,
}
pub struct EditorOptions<'a> {
pub id: Id,
pub placeholder: &'a str,
pub enter_to_generate: bool,
pub mentions: &'a [MentionItem],
/// `Some` shows an `n / max` counter; `Some(None)` shows `n / ∞`.
pub max_length: Option<Option<usize>>,
/// Fill a fixed height (fullscreen mode) instead of auto-growing.
pub fixed_height: Option<f32>,
/// Draw the fullscreen button in the top right.
pub fullscreen_button: bool,
/// Draw the resize grip in the bottom right.
pub resizable: bool,
}
#[derive(Default)]
pub struct EditorOutput {
pub submit: bool,
pub open_fullscreen: bool,
pub focused: bool,
}
/// Draws the editor in the available width.
pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &EditorOptions<'_>, thumbnails: &mut dyn FnMut(&Ui, &str) -> Option<egui::TextureHandle>) -> EditorOutput {
let mut out = EditorOutput::default();
let id = opts.id;
let had_focus = ui.memory(|m| m.has_focus(id));
let filtered = filtered_mentions(state, opts.mentions);
// Keys the field mustn't see: dropdown navigation, and Enter when it generates.
if had_focus {
if !filtered.is_empty() {
handle_dropdown_keys(ui, id, prompt, state, &filtered);
} else if opts.enter_to_generate && consume_plain_enter(ui) {
out.submit = true;
}
}
let width = ui.available_width();
let height = editor_height(ui, state, opts);
let (outer, _) = ui.allocate_exact_size(vec2(width, height.unwrap_or(MIN_HEIGHT)), Sense::hover());
let text_width = (width - RIGHT_GUTTER).max(40.0);
let mentions = opts.mentions;
let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, wrap_width: f32| {
let mut job = highlight(buf.as_str(), mentions);
job.wrap.max_width = wrap_width;
ui.fonts_mut(|f| f.layout_job(job))
};
let return_key = if opts.enter_to_generate { KeyboardShortcut::new(Modifiers::SHIFT, Key::Enter) } else { KeyboardShortcut::new(Modifiers::NONE, Key::Enter) };
let max_h = height.unwrap_or(COLLAPSED_MAX_HEIGHT);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(outer.min, vec2(width, max_h))));
let scroll = egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(max_h).auto_shrink([false, true]);
let scroll = if height.is_some() { scroll.min_scrolled_height(max_h) } else { scroll };
let output = scroll
.show(&mut child, |ui| {
let min_h = height.unwrap_or(MIN_HEIGHT);
egui::TextEdit::multiline(prompt).id(id).frame(egui::Frame::NONE).font(FontId::new(FONT_SIZE, FontFamily::Proportional)).hint_text(egui::RichText::new(opts.placeholder).color(theme::MUTED).size(FONT_SIZE)).desired_width(text_width).desired_rows(1).min_size(vec2(text_width, min_h)).return_key(return_key).layouter(&mut layouter).show(ui)
})
.inner;
let used_h = child.min_rect().height().clamp(MIN_HEIGHT, max_h);
// Grow the reserved space to what the field actually used (auto-grow).
if height.is_none() && used_h > outer.height() {
ui.allocate_exact_size(vec2(width, used_h - outer.height()), Sense::hover());
}
let field_rect = Rect::from_min_size(outer.min, vec2(width, if height.is_some() { max_h } else { used_h }));
if state.focus_requested {
state.focus_requested = false;
ui.memory_mut(|m| m.request_focus(id));
let end = CCursor::new(prompt.chars().count());
let mut st = output.state.clone();
st.cursor.set_char_range(Some(CCursorRange::one(end)));
st.store(ui.ctx(), id);
}
out.focused = output.response.response.has_focus();
if output.response.response.changed() || (out.focused && output.cursor_range.is_some()) {
update_mention_query(state, prompt, output.cursor_range, opts.mentions);
}
if !out.focused && !had_focus {
state.mention = None;
}
if opts.fullscreen_button {
let button = Rect::from_min_size(pos2(field_rect.right() - 24.0, field_rect.top()), vec2(24.0, 24.0));
let resp = ui.interact(button, id.with("fullscreen"), Sense::click()).on_hover_text("Focus mode").on_hover_cursor(egui::CursorIcon::PointingHand);
let p = ui.painter();
p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH });
icons::paint(p, button.shrink(6.0), Icon::Maximize, if resp.hovered() { theme::INK } else { theme::DIM });
out.open_fullscreen = resp.clicked();
}
if opts.resizable {
resize_grip(ui, id, field_rect, state);
}
if let Some(limit) = opts.max_length {
let count = prompt.chars().count();
let over = limit.is_some_and(|max| count > max);
let text = match limit {
Some(max) => format!("{count} / {max}"),
None => format!("{count} / \u{221e}"),
};
let color = if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT };
ui.painter().text(pos2(field_rect.right() - 16.0, field_rect.bottom() + 4.0), Align2::RIGHT_BOTTOM, text, FontId::new(10.0, FontFamily::Monospace), color);
}
if !filtered.is_empty() && out.focused {
mention_dropdown(ui, id, field_rect, prompt, state, &filtered, thumbnails);
}
out
}
/// Whether the prompt exceeds `limit` characters.
pub fn is_over_limit(prompt: &str, limit: Option<usize>) -> bool {
limit.is_some_and(|max| prompt.chars().count() > max)
}
/// The height the field takes: `None` auto-grows (collapsed), `Some` is fixed.
fn editor_height(ui: &Ui, state: &EditorState, opts: &EditorOptions<'_>) -> Option<f32> {
if let Some(h) = opts.fixed_height {
return Some(h);
}
if let Some(h) = state.manual_height {
return Some(h.clamp(MIN_HEIGHT, MAX_HEIGHT));
}
// `clamp(120px, calc(100vh - 700px), 500px)`.
state.expanded.then(|| (ui.ctx().content_rect().height() - 700.0).clamp(120.0, MAX_HEIGHT))
}
/// Consumes a plain (unshifted) Enter press; returns whether there was one.
fn consume_plain_enter(ui: &Ui) -> bool {
ui.input_mut(|i| {
let before = i.events.len();
i.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if !modifiers.shift && !modifiers.command && !modifiers.alt));
before != i.events.len()
})
}
fn handle_dropdown_keys(ui: &Ui, id: Id, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem]) {
let n = filtered.len();
let (down, up, accept, escape) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Tab), i.consume_key(Modifiers::NONE, Key::Escape)));
let Some(query) = state.mention.as_mut() else {
return;
};
if down {
query.selected = (query.selected + 1) % n;
}
if up {
query.selected = (query.selected + n - 1) % n;
}
if escape {
state.mention = None;
return;
}
if accept {
let label = filtered[query.selected.min(n - 1)].label.clone();
insert_mention(ui.ctx(), id, prompt, state, &label);
}
}
/// Opens, updates or closes the dropdown from the text before the cursor.
fn update_mention_query(state: &mut EditorState, prompt: &str, cursor: Option<CCursorRange>, mentions: &[MentionItem]) {
// Keep the highlighted row while the same query stays open (arrow keys move it).
let previous = state.mention.take();
if mentions.is_empty() {
return;
}
let Some(cursor) = cursor else {
return;
};
let at = cursor.primary.index.0;
let before: String = prompt.chars().take(at).collect();
let Some(anchor_byte) = before.rfind('@') else {
return;
};
let after = &before[anchor_byte + 1..];
if after.contains(char::is_whitespace) {
return;
}
let anchor = before[..anchor_byte].chars().count();
let selected = previous.filter(|q| q.anchor == anchor && q.filter == after).map_or(0, |q| q.selected);
state.mention = Some(MentionQuery { anchor, filter: after.to_owned(), selected });
}
fn filtered_mentions<'a>(state: &EditorState, mentions: &'a [MentionItem]) -> Vec<&'a MentionItem> {
let Some(query) = &state.mention else {
return Vec::new();
};
let needle = format!("@{}", query.filter).to_lowercase();
mentions.iter().filter(|m| m.label.to_lowercase().contains(&needle)).collect()
}
/// Replaces `@filter` before the cursor with `label` and a space, and puts the cursor after it.
fn insert_mention(ctx: &egui::Context, id: Id, prompt: &mut String, state: &mut EditorState, label: &str) {
let Some(query) = state.mention.take() else {
return;
};
let chars: Vec<char> = prompt.chars().collect();
let end = (query.anchor + 1 + query.filter.chars().count()).min(chars.len());
let before: String = chars[..query.anchor].iter().collect();
let after: String = chars[end..].iter().collect();
*prompt = format!("{before}{label} {after}");
if let Some(mut st) = egui::TextEdit::load_state(ctx, id) {
st.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(query.anchor + label.chars().count() + 1))));
st.store(ctx, id);
}
}
fn mention_dropdown(ui: &Ui, id: Id, field: Rect, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem], thumbnails: &mut dyn FnMut(&Ui, &str) -> Option<egui::TextureHandle>) {
let selected = state.mention.as_ref().map_or(0, |q| q.selected);
let row_h = 44.0;
let h = 28.0 + row_h * filtered.len() as f32;
let rect = Rect::from_min_size(pos2(field.left(), field.top() - h - 4.0), vec2(256.0, h));
let mut picked = None;
let mut hovered = None;
egui::Area::new(id.with("mentions")).order(Order::Foreground).fixed_pos(rect.min).show(ui.ctx(), |ui| {
let p = ui.painter();
p.rect(rect, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside);
p.text(pos2(rect.left() + 12.0, rect.top() + 14.0), Align2::LEFT_CENTER, "MENTIONS", FontId::new(11.0, theme::mono_bold()), theme::fade(theme::INK, 0.5));
for (i, item) in filtered.iter().enumerate() {
let row = Rect::from_min_size(pos2(rect.left(), rect.top() + 28.0 + row_h * i as f32), vec2(rect.width(), row_h));
let resp = ui.interact(row, id.with(("mention", i)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, i == selected, &item.label));
if resp.hovered() {
hovered = Some(i);
}
let p = ui.painter();
if i == selected {
p.rect_filled(row, 0.0, theme::WASH_HOVER);
}
let tile = Rect::from_min_size(pos2(row.left() + 12.0, row.center().y - 16.0), vec2(32.0, 32.0));
p.rect(tile, 0.0, theme::fade(Color32::BLACK, 0.2), theme::hairline(), egui::StrokeKind::Inside);
match item.preview.as_deref().and_then(|key| thumbnails(ui, key)) {
Some(tex) => {
egui::Image::new(&tex).maintain_aspect_ratio(false).paint_at(ui, tile.shrink(1.0));
},
None => {
let icon = match item.kind {
MentionKind::Image => Icon::Image,
MentionKind::Video => Icon::Video,
MentionKind::Audio => Icon::Music,
};
icons::paint(ui.painter(), tile.shrink(9.0), icon, theme::fade(Color32::WHITE, 0.6));
},
}
ui.painter().text(pos2(tile.right() + 10.0, row.center().y), Align2::LEFT_CENTER, &item.label, FontId::new(14.0, theme::medium()), item.color());
if resp.clicked() {
picked = Some(item.label.clone());
}
}
});
if let (Some(i), Some(q)) = (hovered, state.mention.as_mut()) {
q.selected = i;
}
if let Some(label) = picked {
insert_mention(ui.ctx(), id, prompt, state, &label);
ui.memory_mut(|m| m.request_focus(id));
}
}
/// The three-line grip that resizes the field (`.promptbox-resize-wrap::after`).
fn resize_grip(ui: &mut Ui, id: Id, field: Rect, state: &mut EditorState) {
let grip = Rect::from_min_size(pos2(field.right() - 12.0, field.bottom() - 12.0), vec2(12.0, 12.0));
let resp = ui.interact(grip, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeVertical);
if resp.dragged() {
let current = state.manual_height.unwrap_or(field.height());
state.manual_height = Some((current + resp.drag_delta().y).clamp(MIN_HEIGHT, MAX_HEIGHT));
state.expanded = false;
}
let ink = theme::fade(Color32::WHITE, if resp.hovered() || resp.dragged() { 0.8 } else { 0.5 });
let p = ui.painter();
let c = grip.right_bottom() - vec2(2.0, 2.0);
for k in [3.0, 6.0, 9.0] {
p.line_segment([c - vec2(k, 0.0), c - vec2(0.0, k)], Stroke::new(1.0, ink));
}
}
/// The prompt as a layout job with each known mention coloured and bold.
fn highlight(text: &str, mentions: &[MentionItem]) -> LayoutJob {
let plain = TextFormat { font_id: FontId::new(FONT_SIZE, FontFamily::Proportional), color: theme::INK, ..Default::default() };
let mut job = LayoutJob::default();
if mentions.is_empty() || !text.contains('@') {
job.append(text, 0.0, plain);
return job;
}
let mut sorted: Vec<&MentionItem> = mentions.iter().collect();
sorted.sort_by_key(|m| std::cmp::Reverse(m.label.len()));
let bytes = text.as_bytes();
let (mut start, mut i) = (0, 0);
while i < text.len() {
let hit = (bytes[i] == b'@').then(|| sorted.iter().find(|m| text.get(i..i + m.label.len()).is_some_and(|s| s.eq_ignore_ascii_case(&m.label)))).flatten();
match hit {
Some(m) => {
job.append(&text[start..i], 0.0, plain.clone());
let end = i + m.label.len();
job.append(&text[i..end], 0.0, TextFormat { font_id: FontId::new(FONT_SIZE, theme::semibold()), color: m.color(), ..Default::default() });
start = end;
i = end;
},
None => i += text[i..].chars().next().map_or(1, char::len_utf8),
}
}
job.append(&text[start..], 0.0, plain);
job
}
/// Mention labels for `n` items of `kind` (`@Image1`, `@Image2`, …).
pub fn mention_items(kind: MentionKind, previews: impl Iterator<Item = Option<String>>) -> Vec<MentionItem> {
let name = match kind {
MentionKind::Image => "Image",
MentionKind::Video => "Video",
MentionKind::Audio => "Audio",
};
previews.enumerate().map(|(i, preview)| MentionItem { label: format!("@{name}{}", i + 1), kind, index: i + 1, preview }).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colours_known_mentions_case_insensitively() {
let mentions = mention_items(MentionKind::Image, [None, None].into_iter());
let job = highlight("put @image2 next to @Image1, not @Image3", &mentions);
let coloured: Vec<&str> = job.sections.iter().filter(|s| s.format.color != theme::INK).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect();
assert_eq!(coloured, ["@image2", "@Image1"]);
assert_eq!(mentions[1].color(), IMAGE_COLORS[1]);
}
#[test]
fn counts_characters_not_bytes_for_the_limit() {
assert!(!is_over_limit("\u{65e5}\u{672c}\u{8a9e}", Some(3)));
assert!(is_over_limit("\u{65e5}\u{672c}\u{8a9e}!", Some(3)));
assert!(!is_over_limit("anything", None));
}
}

View File

@ -0,0 +1,346 @@
//! The prompt box shared by the create pages (`PromptBox` in the webapp): the reference deck or
//! keyframe slots beside the prompt, a toolbar of page-specific pickers, clear-all, the credit
//! cost and the generate button; plus drag & drop, pasted images and the focus-mode modal.
pub mod deck;
pub mod editor;
pub mod model_selector;
pub mod pickers;
pub mod refs;
#[cfg(test)]
mod ui_tests;
use std::path::PathBuf;
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::backend::media_cache::MediaCache;
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
use deck::{DeckAction, DeckLimits};
use editor::{EditorOptions, EditorState, MentionItem};
use refs::{RefKind, References};
/// The box's widest (`max-w-5xl`).
pub const MAX_WIDTH: f32 = 1024.0;
/// What sits left of the prompt.
pub enum DeckMode {
None,
/// The fanned reference deck.
References(DeckLimits),
/// First (and maybe last) frame slots.
Keyframes {
show_last: bool,
library: bool,
},
}
/// The page's settings for one frame of the box.
pub struct PromptBoxProps<'a> {
pub id: Id,
pub placeholder: &'a str,
pub enter_to_generate: bool,
pub mentions: &'a [MentionItem],
/// `Some` shows the length counter (`Some(None)` = unlimited).
pub max_length: Option<Option<usize>>,
pub deck: DeckMode,
/// Which kinds a drop or paste may add right now.
pub accepts: &'a [RefKind],
/// The cost of generating, when known.
pub credits: Option<u64>,
pub generate_enabled: bool,
pub generating: bool,
/// Shown on the generate button.
pub generate_tooltip: &'a str,
/// A red note left of the generate button ("Starting frame required").
pub warning: Option<&'a str>,
/// An info strip above the prompt.
pub banner: Option<&'a str>,
}
/// Something the page should do.
#[derive(Debug)]
pub enum PromptBoxAction {
Generate,
Deck(DeckAction),
ClearAll,
DroppedFiles(Vec<PathBuf>),
PastedImage(Vec<u8>),
}
/// UI state that outlives a frame.
#[derive(Default)]
pub struct PromptBoxState {
pub editor: EditorState,
pub fullscreen: bool,
confirm_clear: bool,
focused: bool,
}
/// Draws the box in `ui` (which should be as wide as the box). `toolbar_left` holds the model
/// selector and pickers; `toolbar_right` the count picker. Both may be drawn twice in a frame
/// (box and focus mode), each under its own id.
#[allow(clippy::too_many_arguments)]
pub fn show(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, toolbar_left: &mut dyn FnMut(&mut Ui), toolbar_right: &mut dyn FnMut(&mut Ui)) -> Vec<PromptBoxAction> {
let mut actions = Vec::new();
let frame = egui::Frame::new().fill(theme::fade(theme::CONTROLS, 0.95)).stroke(Stroke::new(1.0, if state.focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(16);
let response = frame.show(ui, |ui| {
if let Some(banner) = props.banner {
info_banner(ui, banner);
}
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 12.0;
deck_widget(ui, props, refs, cache, false, &mut actions);
ui.vertical(|ui| {
let out = editor_ui(ui, prompt, state, props, cache, None);
state.focused = out.focused;
if out.submit {
actions.push(PromptBoxAction::Generate);
}
if out.open_fullscreen {
state.fullscreen = true;
}
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
toolbar_left(ui);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
generate_cluster(ui, props, &mut actions);
clear_all_button(ui, prompt, refs, state, &mut actions);
toolbar_right(ui);
if let Some(warning) = props.warning {
warning_label(ui, warning);
}
});
});
});
let rect = response.response.rect;
expand_toggle(ui, props.id, rect, state);
drop_target(ui, props, rect, &mut actions);
paste_images(ui, props, &mut actions);
if state.fullscreen {
focus_mode(ui.ctx(), prompt, refs, state, props, cache, toolbar_left, &mut actions);
}
if state.confirm_clear {
confirm_clear(ui.ctx(), props.id, state, &mut actions);
}
actions
}
fn editor_ui(ui: &mut Ui, prompt: &mut String, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, fixed_height: Option<f32>) -> editor::EditorOutput {
let fullscreen = fixed_height.is_some();
let opts = EditorOptions { id: if fullscreen { props.id.with("focus-editor") } else { props.id.with("editor") }, placeholder: props.placeholder, enter_to_generate: props.enter_to_generate, mentions: props.mentions, max_length: if fullscreen { None } else { props.max_length }, fixed_height, fullscreen_button: !fullscreen, resizable: !fullscreen };
let ctx = ui.ctx().clone();
editor::show(ui, prompt, &mut state.editor, &opts, &mut |_, key| match cache.get(&ctx, key) {
crate::backend::media_cache::Lookup::Ready(t) => Some(t),
_ => None,
})
}
fn deck_widget(ui: &mut Ui, props: &PromptBoxProps<'_>, refs: &References, cache: &mut MediaCache, always_expanded: bool, actions: &mut Vec<PromptBoxAction>) {
let deck_actions = match &props.deck {
DeckMode::None => Vec::new(),
DeckMode::References(limits) => deck::reference_deck(ui, props.id.with(("deck", always_expanded)), refs, limits, cache, always_expanded),
DeckMode::Keyframes { show_last, library } => deck::keyframe_cards(ui, props.id.with(("keyframes", always_expanded)), refs, *show_last, *library, cache),
};
actions.extend(deck_actions.into_iter().map(PromptBoxAction::Deck));
}
/// The coins + cost and the round generate button (`GenerateIconButton`).
fn generate_cluster(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec<PromptBoxAction>) {
if widgets::generate_button(ui, props.generate_enabled && !props.generating, props.generating, props.generate_tooltip).clicked() {
actions.push(PromptBoxAction::Generate);
}
if let Some(credits) = props.credits {
let ink = theme::fade(theme::INK, if props.generate_enabled { 0.8 } else { 0.4 });
let galley = ui.painter().layout_no_wrap(credits.to_string(), FontId::new(13.0, theme::semibold()), ink);
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 22.0, 36.0), Sense::hover());
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 7.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, ink);
ui.painter().galley(pos2(rect.left() + 20.0, rect.center().y - galley.size().y / 2.0), galley, ink);
resp.on_hover_text(format!("{credits} credit{} cost", if credits == 1 { "" } else { "s" }));
}
}
/// The eraser button and the hairline after it (`PromptClearAllButton`).
fn clear_all_button(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut PromptBoxState, actions: &mut Vec<PromptBoxAction>) {
let (rule, _) = ui.allocate_exact_size(vec2(1.0, 20.0), Sense::hover());
ui.painter().rect_filled(rule, 0.0, theme::fade(theme::INK, 0.15));
let clearable = !prompt.is_empty() || !refs.is_empty();
let resp = ui.add_enabled_ui(clearable, |ui| widgets::ghost(ui, Icon::Eraser, None, "Clear all", false)).inner;
if resp.clicked() {
if refs.is_empty() {
prompt.clear();
actions.push(PromptBoxAction::ClearAll);
} else {
state.confirm_clear = true;
}
}
}
fn confirm_clear(ctx: &egui::Context, id: Id, state: &mut PromptBoxState, actions: &mut Vec<PromptBoxAction>) {
let mut choice = None;
let resp = widgets::modal(ctx, id.with("confirm-clear"), 420.0, |ui| {
ui.label(widgets::heading("Clear all?", 20.0));
ui.add_space(8.0);
ui.label(egui::RichText::new("This clears the prompt and also removes the attached references.").color(theme::MUTED));
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, None, "Clear all", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {
choice = Some(true);
}
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
choice = Some(false);
}
});
});
if resp || choice.is_some() {
state.confirm_clear = false;
}
if choice == Some(true) {
actions.push(PromptBoxAction::ClearAll);
}
}
/// The chevron centred on the box's bottom edge that toggles the tall editor.
fn expand_toggle(ui: &mut Ui, id: Id, rect: Rect, state: &mut PromptBoxState) {
let button = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 6.0), vec2(38.0, 16.0));
let resp = ui.interact(button, id.with("expand"), Sense::click()).on_hover_text(if state.editor.expanded { "Collapse" } else { "Expand" }).on_hover_cursor(egui::CursorIcon::PointingHand);
let ink = theme::fade(Color32::WHITE, if resp.hovered() { 0.9 } else { 0.3 });
icons::paint(ui.painter(), Rect::from_center_size(button.center(), vec2(12.0, 12.0)), if state.editor.expanded { Icon::ChevronUp } else { Icon::ChevronDown }, ink);
if resp.clicked() {
state.editor.expanded = !state.editor.expanded;
state.editor.manual_height = None;
}
}
fn warning_label(ui: &mut Ui, text: &str) {
let pulse = (ui.input(|i| i.time) * 2.0).sin() as f32 * 0.25 + 0.75;
ui.label(egui::RichText::new(text).size(12.0).color(theme::BAD));
let (rect, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
icons::paint(ui.painter(), rect, Icon::Info, theme::fade(theme::BAD, pulse));
ui.ctx().request_repaint_after(std::time::Duration::from_millis(50));
}
fn info_banner(ui: &mut Ui, text: &str) {
egui::Frame::new().fill(theme::fade(theme::ACCENT, 0.12)).stroke(Stroke::new(1.0, theme::fade(theme::ACCENT, 0.35))).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| {
ui.horizontal(|ui| {
icons::icon(ui, Icon::Info, 14.0, theme::ACCENT_INK);
ui.label(egui::RichText::new(text).size(12.5).color(theme::INK));
});
});
ui.add_space(10.0);
}
/// Files dragged over the window: an overlay on the box, and the drop when released.
fn drop_target(ui: &mut Ui, props: &PromptBoxProps<'_>, rect: Rect, actions: &mut Vec<PromptBoxAction>) {
let (hovered, dropped) = ui.input(|i| (i.raw.hovered_files.clone(), i.raw.dropped_files.clone()));
if !dropped.is_empty() {
let paths: Vec<PathBuf> = dropped.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect();
if !paths.is_empty() {
actions.push(PromptBoxAction::DroppedFiles(paths));
}
return;
}
if hovered.is_empty() {
return;
}
let kinds: Vec<Option<RefKind>> = hovered.iter().map(|f| f.path.as_deref().and_then(RefKind::from_path)).collect();
// Without paths (some platforms) we can't tell yet: assume it fits.
let accepted = kinds.iter().all(|k| k.is_none() || k.is_some_and(|k| props.accepts.contains(&k))) && !props.accepts.is_empty();
let keyframes = matches!(props.deck, DeckMode::Keyframes { .. });
let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, props.id.with("drop-overlay")));
p.rect_filled(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.85));
let border = if accepted { theme::fade(Color32::WHITE, 0.6) } else { theme::fade(theme::BAD, 0.8) };
icons::dashed_rect(&p, rect.shrink(1.0), Stroke::new(2.0, border), 6.0, 4.0);
let chip = Rect::from_center_size(rect.center() - vec2(0.0, 22.0), vec2(36.0, 36.0));
p.rect(chip, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside);
icons::paint(&p, chip.shrink(9.0), if accepted { Icon::Image } else { Icon::AlertCircle }, if accepted { theme::INK } else { theme::BAD });
let names: Vec<&str> = props
.accepts
.iter()
.map(|k| match k {
RefKind::Image => "Images",
RefKind::Video => "Videos",
RefKind::Audio => "Audio",
})
.collect();
let (title, subtitle) = if !accepted {
("That file type isn't supported".to_owned(), format!("Accepts {} files", names.join(", ").to_lowercase()))
} else if keyframes {
("Drop to set your frames".to_owned(), "Frames".to_owned())
} else {
("Drop to add references".to_owned(), names.join(", "))
};
p.text(rect.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, title, FontId::new(14.0, theme::semibold()), theme::INK);
p.text(rect.center() + vec2(0.0, 28.0), Align2::CENTER_CENTER, subtitle, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED);
}
/// Ctrl/Cmd+V with an image on the clipboard. egui only forwards text pastes, so this watches for
/// the V key's release and reads the image itself.
fn paste_images(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec<PromptBoxAction>) {
if props.accepts.is_empty() {
return;
}
let released = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } if modifiers.command)));
if !released {
return;
}
let Ok(mut clipboard) = arboard::Clipboard::new() else {
return;
};
let Ok(image) = clipboard.get_image() else {
return;
};
let Some(rgba) = image::RgbaImage::from_raw(image.width as u32, image.height as u32, image.bytes.into_owned()) else {
return;
};
let mut png = Vec::new();
if image::DynamicImage::ImageRgba8(rgba).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() {
actions.push(PromptBoxAction::PastedImage(png));
}
}
/// Focus mode: a large editor in a modal, with the deck and toolbar at hand.
#[allow(clippy::too_many_arguments)]
fn focus_mode(ctx: &egui::Context, prompt: &mut String, refs: &References, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, toolbar_left: &mut dyn FnMut(&mut Ui), actions: &mut Vec<PromptBoxAction>) {
let height = ctx.content_rect().height() * 0.7;
let mut done = false;
let closed = widgets::modal(ctx, props.id.with("focus-mode"), 896.0, |ui| {
ui.set_height(height);
ui.label(widgets::heading("Prompt", 22.0));
ui.add_space(10.0);
let editor_h = (height - 160.0 - if refs.is_empty() { 0.0 } else { 80.0 }).max(120.0);
let out = editor_ui(ui, prompt, state, props, cache, Some(editor_h));
if out.submit {
actions.push(PromptBoxAction::Generate);
}
if let Some(limit) = props.max_length {
let count = prompt.chars().count();
let over = editor::is_over_limit(prompt, limit);
let text = limit.map_or_else(|| format!("{count} / \u{221e}"), |max| format!("{count} / {max}"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
ui.label(egui::RichText::new(text).size(11.0).monospace().color(if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT }));
});
}
ui.push_id("focus-deck", |ui| deck_widget(ui, props, refs, cache, true, actions));
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.push_id("focus-toolbar", |ui| ui.horizontal(|ui| toolbar_left(ui)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, None, "Done", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() {
done = true;
}
ui.push_id("focus-clear", |ui| clear_all_button(ui, prompt, refs, state, actions));
});
});
});
if closed || done {
state.fullscreen = false;
state.editor.focus_requested = true;
}
}

View File

@ -0,0 +1,130 @@
//! The embedded model selector (`ClassyModelSelector variant="embedded"`): a pill with the
//! maker's logo and the model's name, opening "Select Model". Long lists group by family, each
//! family opening a flyout of its variants on hover.
use egui::{Align2, Id, Order, Popup, Rect, Ui, pos2};
use crate::models::{self, ModelInfo};
use crate::prompt_box::pickers;
use crate::ui::widgets::{self, Leading, RowLead};
use crate::ui::{creator_icons, icons};
const PANEL_WIDTH: f32 = 280.0;
const FLYOUT_WIDTH: f32 = 300.0;
/// Draws the selector; returns the id of a newly picked model.
pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option<String> {
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let label = selected.map_or("Select model", |m| m.name.as_str());
let logo = selected.map(|m| creator_icons::texture(ui.ctx(), &m.creator));
let resp = ui.push_id(id, |ui| {
ui.set_max_width(192.0 + 40.0);
match &logo {
Some(t) => widgets::pill(ui, Leading::Logo(t), label, open),
None => widgets::pill(ui, Leading::Icon(icons::Icon::Sparkles), label, open),
}
});
let resp = resp.inner.on_hover_text("Model");
let mut picked = None;
let hovered_family_id = id.with("family");
pickers::popover(&resp, popup_id, |ui| {
ui.set_width(PANEL_WIDTH);
widgets::menu_header(ui, "Select Model");
let max_h = ui.ctx().content_rect().height() * 0.6;
egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| match models::grouped(models) {
Some(families) => {
let mut hovered: Option<String> = ui.data(|d| d.get_temp(hovered_family_id));
for family in &families {
let contains_selected = selected.is_some_and(|s| family.models.iter().any(|m| m.id == s.id));
let first = family.models[0];
let tex = creator_icons::texture(ui.ctx(), &first.creator);
let subtitle = if contains_selected { selected.map(|s| s.name.as_str()) } else { None };
let is_open = hovered.as_deref() == Some(family.name.as_str());
if family.models.len() == 1 {
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &first.name, non_empty(&first.description), contains_selected, false, false);
if row.hovered() {
hovered = None;
}
if row.clicked() {
picked = Some(first.id.clone());
}
continue;
}
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &family.name, subtitle, false, true, is_open);
if row.hovered() || row.clicked() {
hovered = Some(family.name.clone());
}
if hovered.as_deref() == Some(family.name.as_str()) {
if let Some(id) = flyout(ui, id, row.rect, &family.name, &family.models, selected, show_badges) {
picked = Some(id);
}
}
}
ui.data_mut(|d| d.insert_temp(hovered_family_id, hovered));
},
None => {
for m in models {
if model_row(ui, m, selected, show_badges) {
picked = Some(m.id.clone());
}
}
},
});
});
if picked.is_some() {
Popup::close_id(ui.ctx(), popup_id);
ui.data_mut(|d| d.remove::<Option<String>>(hovered_family_id));
}
picked.filter(|p| selected.is_none_or(|s| &s.id != p))
}
/// A family's variants, to the right of its row.
fn flyout(ui: &Ui, id: Id, row: Rect, family: &str, list: &[&ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option<String> {
let mut picked = None;
egui::Area::new(id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {
widgets::popover_frame().show(ui, |ui| {
ui.set_width(FLYOUT_WIDTH);
widgets::menu_header(ui, family);
for m in list {
if model_row(ui, m, selected, show_badges) {
picked = Some(m.id.clone());
}
}
});
});
picked
}
/// One model: logo tile, name, description (and capability badges for video).
fn model_row(ui: &mut Ui, m: &ModelInfo, selected: Option<&ModelInfo>, show_badges: bool) -> bool {
let tex = creator_icons::texture(ui.ctx(), &m.creator);
let is_selected = selected.is_some_and(|s| s.id == m.id);
let badges = if show_badges { capability_badges(m) } else { String::new() };
let subtitle = match (m.description.is_empty(), badges.is_empty()) {
(true, true) => None,
(false, true) => Some(m.description.clone()),
(true, false) => Some(badges),
(false, false) => Some(format!("{} \u{b7} {badges}", m.description)),
};
widgets::menu_row(ui, RowLead::Logo(&tex), &m.name, subtitle.as_deref(), is_selected, false, false).clicked()
}
/// The video picker's capability badges ("Audio Support", "Start/End", "Reference").
fn capability_badges(m: &ModelInfo) -> String {
let mut badges = Vec::new();
if m.sound_toggle {
badges.push("Audio Support");
}
if m.start_frame && m.end_frame {
badges.push("Start/End");
}
if m.supports_reference_mode() {
badges.push("Reference");
}
badges.join(" \u{b7} ")
}
fn non_empty(s: &str) -> Option<&str> {
(!s.is_empty()).then_some(s)
}

View File

@ -0,0 +1,128 @@
//! Toolbar pickers: a pill (or quiet icon button) that opens a popover above it with a titled
//! option list, the duration slider, and on/off toggles.
use egui::{Popup, PopupCloseBehavior};
use egui::{Id, RectAlign, Response, Ui};
use crate::theme;
use crate::ui::icons::Icon;
use crate::ui::widgets::{self, Leading, RowLead};
/// What an option shows at its left.
#[derive(Clone, Debug, PartialEq)]
pub enum OptionLead {
None,
Icon(Icon),
Aspect(Option<(f32, f32)>),
}
#[derive(Clone, Debug)]
pub struct PickOption<T> {
pub value: T,
pub label: String,
pub subtitle: Option<String>,
pub lead: OptionLead,
}
impl<T> PickOption<T> {
pub fn new(value: T, label: impl Into<String>) -> Self {
Self { value, label: label.into(), subtitle: None, lead: OptionLead::None }
}
pub fn lead(mut self, lead: OptionLead) -> Self {
self.lead = lead;
self
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
}
/// How a picker's trigger looks.
pub enum Trigger<'a> {
/// A toolbar pill with a lead and the selected label.
Pill(Leading<'a>),
/// A quiet 36 px icon button followed by the selected label (the count picker).
Ghost(Icon),
}
/// A picker whose popover lists `options`; returns the newly chosen value.
pub fn option_picker<T: Clone + PartialEq>(ui: &mut Ui, id: Id, trigger: Trigger<'_>, title: &str, tooltip: &str, current: &T, options: &[PickOption<T>]) -> Option<T> {
let selected = options.iter().find(|o| o.value == *current);
let label = selected.map_or("", |o| o.label.as_str());
// Scoped to the parent, so the same picker in the box and in focus mode stay separate.
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let resp = ui.push_id(id, |ui| match trigger {
Trigger::Pill(lead) => widgets::pill(ui, lead, label, open),
Trigger::Ghost(icon) => widgets::ghost(ui, icon, Some(label), tooltip, open),
});
let resp = resp.inner.on_hover_text(tooltip);
let mut picked = None;
popover(&resp, popup_id, |ui| {
ui.set_min_width(180.0);
widgets::menu_header(ui, title);
for option in options {
let lead = match option.lead {
OptionLead::None => RowLead::None,
OptionLead::Icon(icon) => RowLead::Icon(icon),
OptionLead::Aspect(ratio) => RowLead::Aspect(ratio),
};
let is_selected = option.value == *current;
if widgets::menu_row(ui, lead, &option.label, option.subtitle.as_deref(), is_selected, false, false).clicked() {
picked = Some(option.value.clone());
}
}
});
if picked.is_some() {
Popup::close_id(ui.ctx(), popup_id);
}
picked.filter(|v| v != current)
}
/// Opens a popover above `anchor` (toggled by clicking it) and fills it with `content`.
pub fn popover(anchor: &Response, popup_id: Id, content: impl FnOnce(&mut Ui)) {
Popup::from_toggle_button_response(anchor).id(popup_id).align(RectAlign::TOP_START).align_alternatives(&[RectAlign::BOTTOM_START, RectAlign::TOP_END]).gap(6.0).frame(widgets::popover_frame()).close_behavior(PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
content(ui);
});
}
/// The duration popover: a clock pill reading `5s`, opening a 1-second-step slider between the
/// model's minimum and maximum. Returns the new value.
pub fn duration_picker(ui: &mut Ui, id: Id, current: u32, min: u32, max: u32) -> Option<u32> {
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let resp = ui.push_id(id, |ui| widgets::pill(ui, Leading::Icon(Icon::Clock), &format!("{current}s"), open)).inner.on_hover_text("Duration");
let mut value = current;
popover(&resp, popup_id, |ui| {
ui.set_width(240.0);
widgets::menu_header(ui, "Duration");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.style_mut().spacing.slider_width = 210.0;
ui.add(egui::Slider::new(&mut value, min..=max).step_by(1.0).suffix("s").show_value(false));
});
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(egui::RichText::new(format!("{min}s")).size(11.0).color(theme::FAINT));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
ui.label(egui::RichText::new(format!("{max}s")).size(11.0).color(theme::FAINT));
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new(format!("{value}s")).size(13.0).family(theme::semibold()).color(theme::INK));
});
});
});
});
(value != current).then_some(value)
}
/// An on/off toolbar toggle (sound): a pill that's highlighted while on.
pub fn toggle(ui: &mut Ui, id: Id, icon: Icon, label: &str, on: bool, tooltip: &str) -> bool {
let resp = ui.push_id(id, |ui| widgets::pill(ui, Leading::Icon(icon), label, on)).inner.on_hover_text(tooltip);
resp.clicked()
}

View File

@ -0,0 +1,169 @@
//! Reference media attached to a prompt: images, videos and audio clips, either uploaded from
//! disk or picked from the library.
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RefKind {
Image,
Video,
Audio,
}
impl RefKind {
pub fn noun(self) -> &'static str {
match self {
RefKind::Image => "image",
RefKind::Video => "video",
RefKind::Audio => "audio",
}
}
/// Which kind a file is, by extension (the webapp's drop routing lists).
pub fn from_path(path: &std::path::Path) -> Option<Self> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "avif" => Some(RefKind::Image),
"mp4" | "mov" | "webm" | "mkv" | "avi" | "m4v" => Some(RefKind::Video),
"mp3" | "wav" | "ogg" | "flac" | "aac" | "m4a" | "opus" => Some(RefKind::Audio),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum RefStatus {
Uploading,
Ready,
}
/// One attached reference.
#[derive(Clone, Debug)]
pub struct RefMedia {
pub id: u64,
pub kind: RefKind,
pub status: RefStatus,
/// The server's media file token, once uploaded (or picked from the library).
pub token: Option<String>,
/// Media cache key of the thumbnail (a CDN URL or a `local://` key).
pub preview: Option<String>,
/// The full-size URL, for the preview modal.
pub full_url: Option<String>,
/// Seconds, for videos and audio (counts toward the model's total-duration limit).
pub duration_secs: f32,
}
impl RefMedia {
pub fn uploading(kind: RefKind, preview: Option<String>) -> Self {
Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), kind, status: RefStatus::Uploading, token: None, preview, full_url: None, duration_secs: 0.0 }
}
pub fn from_library(kind: RefKind, token: String, preview: Option<String>, full_url: Option<String>, duration_secs: f32) -> Self {
Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), kind, status: RefStatus::Ready, token: Some(token), preview, full_url, duration_secs }
}
}
/// Where an image goes: the reference deck, or one of the two keyframe slots.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ImageSlot {
Reference,
FirstFrame,
LastFrame,
}
/// Everything attached to one prompt box.
#[derive(Clone, Debug, Default)]
pub struct References {
pub images: Vec<RefMedia>,
pub videos: Vec<RefMedia>,
pub audios: Vec<RefMedia>,
pub first_frame: Option<RefMedia>,
pub last_frame: Option<RefMedia>,
}
impl References {
pub fn is_empty(&self) -> bool {
self.images.is_empty() && self.videos.is_empty() && self.audios.is_empty() && self.first_frame.is_none() && self.last_frame.is_none()
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn list(&self, kind: RefKind) -> &Vec<RefMedia> {
match kind {
RefKind::Image => &self.images,
RefKind::Video => &self.videos,
RefKind::Audio => &self.audios,
}
}
pub fn list_mut(&mut self, kind: RefKind) -> &mut Vec<RefMedia> {
match kind {
RefKind::Image => &mut self.images,
RefKind::Video => &mut self.videos,
RefKind::Audio => &mut self.audios,
}
}
pub fn total_secs(&self, kind: RefKind) -> f32 {
self.list(kind).iter().map(|r| r.duration_secs).sum()
}
pub fn any_uploading(&self) -> bool {
let slots = self.first_frame.iter().chain(self.last_frame.iter());
self.images.iter().chain(&self.videos).chain(&self.audios).chain(slots).any(|r| r.status == RefStatus::Uploading)
}
/// Finds a reference anywhere by id.
pub fn find_mut(&mut self, id: u64) -> Option<&mut RefMedia> {
let slots = self.first_frame.iter_mut().chain(self.last_frame.iter_mut());
self.images.iter_mut().chain(self.videos.iter_mut()).chain(self.audios.iter_mut()).chain(slots).find(|r| r.id == id)
}
/// Removes a reference anywhere by id.
pub fn remove(&mut self, id: u64) {
for list in [&mut self.images, &mut self.videos, &mut self.audios] {
list.retain(|r| r.id != id);
}
if self.first_frame.as_ref().is_some_and(|r| r.id == id) {
self.first_frame = None;
}
if self.last_frame.as_ref().is_some_and(|r| r.id == id) {
self.last_frame = None;
}
}
pub fn swap_frames(&mut self) {
std::mem::swap(&mut self.first_frame, &mut self.last_frame);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn routes_files_by_extension() {
assert_eq!(RefKind::from_path(Path::new("a/B.PNG")), Some(RefKind::Image));
assert_eq!(RefKind::from_path(Path::new("clip.mov")), Some(RefKind::Video));
assert_eq!(RefKind::from_path(Path::new("beat.m4a")), Some(RefKind::Audio));
assert_eq!(RefKind::from_path(Path::new("notes.txt")), None);
}
#[test]
fn swapping_frames_and_removing_by_id() {
let mut refs = References::default();
let (a, b) = (RefMedia::from_library(RefKind::Image, "a".into(), None, None, 0.0), RefMedia::from_library(RefKind::Image, "b".into(), None, None, 0.0));
let a_id = a.id;
refs.first_frame = Some(a);
refs.last_frame = Some(b);
refs.swap_frames();
assert_eq!(refs.first_frame.as_ref().and_then(|r| r.token.as_deref()), Some("b"));
refs.remove(a_id);
assert!(refs.last_frame.is_none());
}
}

View File

@ -0,0 +1,143 @@
//! Drives the prompt box headlessly (egui_kittest): generate gating, Enter-to-generate, the
//! pickers, @-mentions and clear-all.
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use crate::backend::media_cache::MediaCache;
use crate::models::ModelInfo;
use crate::pages::toolbar;
use crate::prompt_box::deck::DeckLimits;
use crate::prompt_box::editor::{MentionKind, mention_items};
use crate::prompt_box::refs::{RefKind, References};
use crate::prompt_box::{DeckMode, PromptBoxProps, PromptBoxState, show};
struct State {
// Keeps the media cache's runtime alive.
_rt: tokio::runtime::Runtime,
cache: MediaCache,
prompt: String,
refs: References,
box_state: PromptBoxState,
model: ModelInfo,
aspect: String,
enter_to_generate: bool,
with_mentions: bool,
actions: Vec<String>,
/// The harness draws one frame while building: fonts are installed then and used from the next.
fonts_ready: bool,
}
fn state() -> State {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("runtime");
let cache = MediaCache::new(rt.handle().clone(), reqwest::Client::new());
let model = ModelInfo { id: "nano_banana_pro".into(), name: "Nano Banana Pro".into(), aspect_ratios: vec!["square".into(), "wide_sixteen_by_nine".into()], image_refs_max: 4, batch_default: 1, ..Default::default() };
State { _rt: rt, cache, prompt: String::new(), refs: References::default(), box_state: PromptBoxState::default(), model, aspect: "square".into(), enter_to_generate: false, with_mentions: false, actions: Vec::new(), fonts_ready: false }
}
fn build(state: State) -> Harness<'static, State> {
let mut harness = Harness::builder().with_size(egui::vec2(1100.0, 600.0)).build_ui_state(
|ui, s: &mut State| {
if !s.fonts_ready {
crate::theme::apply(ui.ctx());
s.fonts_ready = true;
return;
}
let mentions = if s.with_mentions { mention_items(MentionKind::Image, [None, None].into_iter()) } else { Vec::new() };
let props = PromptBoxProps { id: egui::Id::new("test-box"), placeholder: "Describe what you want in the image...", enter_to_generate: s.enter_to_generate, mentions: &mentions, max_length: Some(Some(1000)), deck: DeckMode::References(DeckLimits { max_images: 4, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: false }), accepts: &[RefKind::Image], credits: Some(15), generate_enabled: !s.prompt.trim().is_empty(), generating: false, generate_tooltip: "Generate", warning: None, banner: None };
let (model, aspect) = (&s.model, &mut s.aspect);
let mut left = |ui: &mut egui::Ui| {
if let Some(v) = toolbar::aspect_ratio(ui, egui::Id::new("test-aspect"), model, aspect) {
*aspect = v;
}
};
let mut right = |_: &mut egui::Ui| {};
let actions = show(ui, &mut s.prompt, &s.refs, &mut s.box_state, &props, &mut s.cache, &mut left, &mut right);
s.actions.extend(actions.iter().map(|a| format!("{a:?}")));
},
state,
);
harness.run();
harness
}
fn editor<'h>(harness: &'h Harness<'_, State>) -> egui_kittest::Node<'h> {
harness.get_by_role(Role::MultilineTextInput)
}
#[test]
fn generate_needs_a_prompt() {
let mut harness = build(state());
harness.get_by_label("Generate").click();
harness.run();
assert!(harness.state().actions.is_empty(), "disabled without a prompt");
harness.state_mut().prompt = "a red knight".into();
harness.run();
harness.get_by_label("Generate").click();
harness.run();
assert_eq!(harness.state().actions, ["Generate"]);
}
#[test]
fn enter_generates_only_when_the_setting_is_on() {
let mut harness = build(state());
editor(&harness).focus();
harness.run();
editor(&harness).type_text("hello");
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state().prompt, "hello\n");
assert!(harness.state().actions.is_empty());
let mut s = state();
s.enter_to_generate = true;
let mut harness = build(s);
editor(&harness).focus();
harness.run();
editor(&harness).type_text("hello");
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state().prompt, "hello");
assert_eq!(harness.state().actions, ["Generate"]);
}
#[test]
fn aspect_ratio_picker_opens_and_picks() {
let mut harness = build(state());
harness.get_by_label("Square").click();
harness.run();
harness.get_by_label("16:9 (Wide)").click();
harness.run();
assert_eq!(harness.state().aspect, "wide_sixteen_by_nine");
assert!(harness.query_by_label("16:9 (Wide)").is_some(), "the pill now shows the pick");
}
#[test]
fn typing_at_offers_mentions_and_enter_inserts_one() {
let mut s = state();
s.with_mentions = true;
let mut harness = build(s);
editor(&harness).focus();
harness.run();
editor(&harness).type_text("put @Im");
harness.run();
assert!(harness.query_by_label("@Image2").is_some(), "dropdown lists the mentions");
harness.key_press(egui::Key::ArrowDown);
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state().prompt, "put @Image2 ");
assert!(harness.state().actions.is_empty(), "Enter picked a mention, it didn't generate");
}
#[test]
fn clear_all_empties_the_prompt() {
let mut s = state();
s.prompt = "something".into();
let mut harness = build(s);
harness.get_by_label("Clear all").click();
harness.run();
assert_eq!(harness.state().prompt, "");
assert_eq!(harness.state().actions, ["ClearAll"]);
}

View File

@ -0,0 +1,404 @@
//! The app frame: a custom title bar (mark, sidebar toggle, account, caption buttons), a sidebar
//! flush with the window edge, and the content panel's header (breadcrumbs and feed toggles).
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::feed::grid::ViewMode;
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::{widgets, window};
pub const SIDEBAR_WIDTH: f32 = 232.0;
pub const TITLEBAR_HEIGHT: f32 = 40.0;
pub const HEADER_HEIGHT: f32 = 48.0;
/// The content panel's gap to the window edges, and its corner radius (Claude-app style).
pub const PANEL_GAP: i8 = 8;
pub const PANEL_RADIUS: u8 = 8;
const ROW_HEIGHT: f32 = 32.0;
pub const DISCORD_URL: &str = "https://discord.gg/artcraft";
pub const GITHUB_URL: &str = "https://github.com/storytold/artcraft";
pub const TUTORIALS_URL: &str = "https://www.youtube.com/@OfficialArtCraftStudios";
pub const WEB_APP_URL: &str = "https://app.getartcraft.com";
/// Every page in the navigation.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Page {
Home,
#[default]
CreateImage,
CreateVideo,
CreateAudio,
Object3d,
World3d,
EditImage,
Stage3d,
VideoEditor,
RemoveBackground,
FrameExtractor,
Library,
}
impl Page {
pub fn label(self) -> &'static str {
match self {
Page::Home => "Home",
Page::CreateImage => "Image",
Page::CreateVideo => "Video",
Page::CreateAudio => "Audio",
Page::Object3d => "3D Object",
Page::World3d => "3D World",
Page::EditImage => "Edit Image",
Page::Stage3d => "3D Stage",
Page::VideoEditor => "Edit Video",
Page::RemoveBackground => "BG Remove",
Page::FrameExtractor => "Frame Extract",
Page::Library => "Library",
}
}
fn icon(self) -> Icon {
match self {
Page::Home => Icon::Home,
Page::CreateImage => Icon::Image,
Page::CreateVideo => Icon::Video,
Page::CreateAudio => Icon::Music,
Page::Object3d | Page::Stage3d => Icon::Box,
Page::World3d => Icon::Globe,
Page::EditImage => Icon::Pencil,
Page::VideoEditor | Page::FrameExtractor => Icon::Film,
Page::RemoveBackground => Icon::Wand,
Page::Library => Icon::Library,
}
}
/// The breadcrumb trail (`Create › Image`).
pub fn crumbs(self) -> (&'static str, &'static str) {
match self {
Page::Home => ("ArtCraft", "Home"),
Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Object3d | Page::World3d => ("Create", self.label()),
Page::Library => ("Assets", "Library"),
_ => ("Studio", self.label()),
}
}
/// A page by its web-app style slug (`create-image`), for `ARTCRAFT_PAGE`.
pub fn from_slug(slug: &str) -> Option<Self> {
Some(match slug {
"home" => Page::Home,
"create-image" | "image" => Page::CreateImage,
"create-video" | "video" => Page::CreateVideo,
"library" => Page::Library,
_ => return None,
})
}
/// Pages the native app has; the rest open a "coming soon" page.
pub fn is_ready(self) -> bool {
matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::Library)
}
/// The same tool in the web app.
pub fn web_path(self) -> &'static str {
match self {
Page::CreateAudio => "/create-audio",
Page::Object3d => "/create-object",
Page::World3d => "/create-world",
Page::EditImage => "/pagedraw",
Page::Stage3d => "/pagescene",
Page::VideoEditor => "/video-editor",
Page::RemoveBackground => "/create-vfx",
Page::FrameExtractor => "/frame-extractor",
Page::CreateVideo => "/create-video",
Page::Library => "/library",
_ => "/create-image",
}
}
}
const SECTIONS: [(&str, &[Page]); 3] = [("Create", &[Page::CreateImage, Page::CreateVideo, Page::CreateAudio, Page::Object3d, Page::World3d]), ("Studio", &[Page::EditImage, Page::Stage3d, Page::VideoEditor, Page::RemoveBackground, Page::FrameExtractor]), ("Assets", &[Page::Library])];
/// Who's signed in, for the sidebar and top bar.
pub struct Account<'a> {
pub username: Option<&'a str>,
pub display_name: Option<&'a str>,
pub credits: Option<u64>,
}
/// What the user did in the frame.
#[derive(Debug, PartialEq)]
pub enum ShellAction {
Navigate(Page),
ToggleSidebar,
SignIn,
SignOut,
OpenSettings,
SetViewMode(ViewMode),
ToggleSelect,
}
/// The custom title bar: the ArtCraft mark (home) and the sidebar toggle at the left, the
/// account and (on Windows and Linux) the caption buttons at the right; the rest drags the window.
pub fn titlebar(ui: &mut Ui, account: &Account<'_>, mark: &egui::TextureHandle) -> Option<ShellAction> {
let mut action = None;
window::title_bar_drag(ui);
let rect = ui.max_rect();
let mut bar = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
let ui = &mut bar;
ui.spacing_mut().item_spacing.x = 4.0;
ui.add_space(window::leading_inset() + 10.0);
let (mark_rect, mark_resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
let hovered = mark_resp.hovered();
if hovered {
ui.painter().rect_filled(mark_rect, theme::RADIUS, theme::WASH);
}
ui.painter().image(mark.id(), Rect::from_center_size(mark_rect.center(), vec2(18.0, 16.0)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), theme::ACCENT);
if mark_resp.on_hover_text("Home").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
action = Some(ShellAction::Navigate(Page::Home));
}
if widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() {
action = Some(ShellAction::ToggleSidebar);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if window::custom_caption_buttons() {
ui.spacing_mut().item_spacing.x = 0.0;
window::caption_buttons(ui, TITLEBAR_HEIGHT);
ui.add_space(12.0);
ui.spacing_mut().item_spacing.x = 8.0;
} else {
ui.add_space(12.0);
}
match account.username {
Some(_) => {
if let Some(a) = account_menu(ui, account) {
action = Some(a);
}
if let Some(credits) = account.credits {
if credits_pill(ui, credits).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing")));
}
}
},
None => {
if widgets::button(ui, None, "Sign in", widgets::Kind::Primary, 0.0, 28.0).clicked() {
action = Some(ShellAction::SignIn);
}
},
}
});
action
}
/// The sidebar, flush with the window's left edge. Returns the user's action, if any.
pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect();
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 4.0))));
let ui = &mut inner;
ui.spacing_mut().item_spacing.y = 2.0;
if nav_row(ui, Page::Home, page == Page::Home) {
action = Some(ShellAction::Navigate(Page::Home));
}
for (title, pages) in SECTIONS {
ui.add_space(10.0);
widgets::section_label(ui, title, theme::SIDEBAR_INK);
for &p in pages {
if nav_row(ui, p, page == p) {
action = Some(ShellAction::Navigate(p));
}
}
}
ui.add_space(10.0);
widgets::section_label(ui, "Resources", theme::SIDEBAR_INK);
for (label, icon, url) in [("Tutorials", Icon::Play, TUTORIALS_URL), ("Discord", Icon::Globe, DISCORD_URL), ("GitHub", Icon::ExternalLink, GITHUB_URL)] {
if link_row(ui, label, icon) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
// The account sits at the bottom, like the webapp's download button.
let bottom = Rect::from_min_max(pos2(rect.left() + 10.0, rect.bottom() - 56.0), pos2(rect.right() - 10.0, rect.bottom() - 8.0));
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(bottom));
match account.username {
Some(username) => {
if account_row(&mut foot, account.display_name.unwrap_or(username), account.credits).clicked() {
action = Some(ShellAction::OpenSettings);
}
},
None => {
if widgets::button(&mut foot, Some(Icon::LogIn), "Sign in", widgets::Kind::Primary, bottom.width(), 40.0).clicked() {
action = Some(ShellAction::SignIn);
}
},
}
action
}
fn nav_row(ui: &mut Ui, page: Page, selected: bool) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
if selected {
p.rect_filled(rect, theme::RADIUS, theme::WASH_SELECTED);
p.rect_filled(Rect::from_min_size(pos2(rect.left() - 8.0, rect.top() + 7.0), vec2(2.0, 18.0)), 0.0, Color32::WHITE);
} else if hovered {
p.rect_filled(rect, theme::RADIUS, theme::WASH);
}
let ink = if selected || hovered { Color32::WHITE } else { theme::SIDEBAR_INK };
let ready = page.is_ready();
let ink = if ready { ink } else { theme::fade(ink, 0.7) };
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(15.0, 15.0)), page.icon(), ink);
let font = FontId::new(14.0, if selected { theme::medium() } else { egui::FontFamily::Proportional });
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, page.label(), font, ink);
if !ready {
widgets::badge(p, ui, pos2(rect.right() - 6.0, rect.center().y), "Soon", theme::FAINT);
}
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
fn link_row(ui: &mut Ui, label: &str, icon: Icon) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
if hovered {
p.rect_filled(rect, theme::RADIUS, theme::WASH);
}
let ink = if hovered { Color32::WHITE } else { theme::SIDEBAR_INK };
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(15.0, 15.0)), icon, ink);
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::new(14.0, egui::FontFamily::Proportional), ink);
icons::paint(p, Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(11.0, 11.0)), Icon::ExternalLink, theme::FAINT);
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
fn account_row(ui: &mut Ui, name: &str, credits: Option<u64>) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
p.rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside);
let avatar = Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(28.0, 28.0));
p.circle_filled(avatar.center(), 14.0, theme::ACCENT);
let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
p.text(avatar.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE);
p.text(pos2(avatar.right() + 10.0, rect.center().y - 8.0), Align2::LEFT_CENTER, name, FontId::new(13.5, theme::medium()), theme::INK);
let sub = credits.map_or_else(|| "Account".to_owned(), |c| format!("{} credits", group_thousands(c)));
p.text(pos2(avatar.right() + 10.0, rect.center().y + 9.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED);
icons::paint(p, Rect::from_center_size(pos2(rect.right() - 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Settings, theme::DIM);
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Settings")
}
/// The content panel's header: breadcrumbs, and the select and grid/list toggles on feed pages.
pub fn page_header(ui: &mut Ui, page: Page, feed_toggles: Option<(ViewMode, bool)>) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect();
ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, theme::hairline());
let mut bar = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(20.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let ui = &mut bar;
ui.spacing_mut().item_spacing.x = 8.0;
let (section, leaf) = page.crumbs();
widgets::hud(ui, section, theme::MUTED);
let (chev, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
icons::paint(ui.painter(), chev, Icon::ChevronRight, theme::FAINT);
let galley = widgets::caps(ui, leaf, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::BG);
let (crumb, _) = ui.allocate_exact_size(galley.size() + vec2(12.0, 8.0), Sense::hover());
ui.painter().rect_filled(crumb, 0.0, theme::INK);
ui.painter().galley(crumb.center() - galley.size() / 2.0, galley, theme::BG);
if let Some((mode, selecting)) = feed_toggles {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(m) = view_toggle(ui, mode) {
action = Some(ShellAction::SetViewMode(m));
}
if segmented(ui, &[(Icon::SquareCheck, if selecting { "Exit selection" } else { "Select items" }, selecting)]).is_some() {
action = Some(ShellAction::ToggleSelect);
}
});
}
action
}
fn credits_pill(ui: &mut Ui, credits: u64) -> egui::Response {
let text = group_thousands(credits);
let galley = ui.painter().layout_no_wrap(text, FontId::new(13.0, theme::semibold()), theme::INK);
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 40.0, 30.0), Sense::click());
let hovered = resp.hovered();
ui.painter().rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, theme::INK);
ui.painter().galley(pos2(rect.left() + 28.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK);
resp.on_hover_text("Your credit balance \u{b7} Buy credits").on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn account_menu(ui: &mut Ui, account: &Account<'_>) -> Option<ShellAction> {
let name = account.display_name.or(account.username).unwrap_or("Account");
let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::click());
ui.painter().circle(rect.center(), 15.0, theme::ACCENT, Stroke::new(if resp.hovered() { 2.0 } else { 0.0 }, Color32::WHITE));
let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
ui.painter().text(rect.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
let mut action = None;
egui::Popup::menu(&resp).frame(widgets::popover_frame()).align(egui::RectAlign::BOTTOM_END).show(|ui| {
ui.set_min_width(200.0);
ui.label(egui::RichText::new(name).size(13.0).family(theme::semibold()));
if let Some(u) = account.username {
ui.label(egui::RichText::new(format!("@{u}")).size(11.5).color(theme::MUTED));
}
ui.add_space(4.0);
if widgets::menu_row(ui, widgets::RowLead::Icon(Icon::Settings), "Settings", None, false, false, false).clicked() {
action = Some(ShellAction::OpenSettings);
}
if widgets::menu_row(ui, widgets::RowLead::Icon(Icon::LogOut), "Log out", None, false, false, false).clicked() {
action = Some(ShellAction::SignOut);
}
});
action
}
/// The grid / list toggle (`GalleryViewToggle`).
fn view_toggle(ui: &mut Ui, mode: ViewMode) -> Option<ViewMode> {
// Right-to-left: list first so grid ends up on the left.
let picked = segmented(ui, &[(Icon::List, "List view", mode == ViewMode::List), (Icon::LayoutGrid, "Grid view", mode == ViewMode::Grid)])?;
Some(if picked == 0 { ViewMode::List } else { ViewMode::Grid })
}
/// A segmented group of 24 px icon buttons; returns the clicked index.
fn segmented(ui: &mut Ui, buttons: &[(Icon, &str, bool)]) -> Option<usize> {
let w = 4.0 + 26.0 * buttons.len() as f32;
let (rect, _) = ui.allocate_exact_size(vec2(w, 30.0), Sense::hover());
ui.painter().rect(rect, theme::RADIUS, Color32::from_white_alpha(10), Stroke::new(1.0, Color32::from_white_alpha(20)), egui::StrokeKind::Inside);
let mut clicked = None;
for (i, (icon, tip, active)) in buttons.iter().enumerate() {
let b = Rect::from_min_size(pos2(rect.left() + 3.0 + 26.0 * i as f32, rect.top() + 3.0), vec2(24.0, 24.0));
let resp = ui.interact(b, Id::new(("segmented", *tip)), Sense::click()).on_hover_text(*tip).on_hover_cursor(egui::CursorIcon::PointingHand);
if *active || resp.hovered() {
ui.painter().rect_filled(b, theme::RADIUS, theme::WASH_HOVER);
}
icons::paint(ui.painter(), b.shrink(5.0), *icon, if *active { Color32::WHITE } else { theme::DIM });
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// `1234567` → `1,234,567` (`toLocaleString`).
pub fn group_thousands(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn groups_thousands_like_to_locale_string() {
assert_eq!(group_thousands(5663), "5,663");
assert_eq!(group_thousands(1_234_567), "1,234,567");
assert_eq!(group_thousands(15), "15");
}
}

View File

@ -0,0 +1,193 @@
//! Colours, type and shapes of the ArtCraft webapp's brutalist system
//! (`artcraft-services/frontend/apps/artcraft-webapp`): near-black surfaces, white/15 hairlines,
//! Archivo display type, Inter body text, Geist Mono labels and 3 px corners on anything
//! pressable. Large surfaces (panels, cards, media tiles) stay square.
use std::sync::Arc;
use egui::epaint::text::VariationCoords;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
const fn hex(rgb: u32) -> Color32 {
let [_, r, g, b] = rgb.to_be_bytes();
Color32::from_rgb(r, g, b)
}
const fn alpha(rgb: u32, a: u8) -> Color32 {
let [_, r, g, b] = rgb.to_be_bytes();
Color32::from_rgba_unmultiplied_const(r, g, b, a)
}
/// `ui-background`: the page behind the feed.
pub const BG: Color32 = hex(0x121316);
/// `ui-panel`: the top bar, modals and the lightbox.
pub const PANEL: Color32 = hex(0x101014);
/// `ui-controls`: the sidebar, the prompt box and toolbar buttons.
pub const CONTROLS: Color32 = hex(0x1e1f22);
/// The window chrome behind the content panel: the title bar and the sidebar.
pub const CHROME: Color32 = hex(0x0a0a0c);
/// `ui-sunken`: wells and inputs.
pub const SUNKEN: Color32 = hex(0x060607);
/// `base-fg`: body text.
pub const INK: Color32 = hex(0xf2f1ee);
pub const INK_STRONG: Color32 = Color32::WHITE;
/// `base-fg/60`: secondary text and placeholders.
pub const MUTED: Color32 = alpha(0xf2f1ee, 153);
/// `base-fg/50`: idle toolbar icons.
pub const DIM: Color32 = alpha(0xf2f1ee, 128);
/// `base-fg/40`: counters and captions.
pub const FAINT: Color32 = alpha(0xf2f1ee, 102);
/// The sidebar's link colour (`white/72`).
pub const SIDEBAR_INK: Color32 = alpha(0xffffff, 184);
/// `white/15`: every hairline.
pub const LINE: Color32 = alpha(0xffffff, 38);
/// `white/25`: dashed drop targets.
pub const LINE_DASHED: Color32 = alpha(0xffffff, 64);
/// `base-fg/40`: outlined buttons and corner marks.
pub const LINE_STRONG: Color32 = alpha(0xf2f1ee, 102);
/// `white/5`: quiet fills.
pub const WASH: Color32 = alpha(0xffffff, 13);
/// `white/8`: the selected sidebar row.
pub const WASH_SELECTED: Color32 = alpha(0xffffff, 20);
/// `white/10`: hovered menu rows.
pub const WASH_HOVER: Color32 = alpha(0xffffff, 26);
/// The brand blue (`primary`).
pub const ACCENT: Color32 = hex(0x2d81ff);
/// `primary-400`: progress bars and selection rings.
pub const ACCENT_400: Color32 = hex(0x59a7ff);
/// `ui-accent-ink`: blue text and focus rings.
pub const ACCENT_INK: Color32 = hex(0x74aaff);
/// The inverted primary button (white block, near-black text).
pub const INVERT_BG: Color32 = Color32::WHITE;
pub const INVERT_FG: Color32 = hex(0x0b0b0c);
/// `red-400`: failure text.
pub const BAD: Color32 = hex(0xf87171);
/// `red`: destructive fills.
pub const DANGER: Color32 = hex(0xd33242);
/// The "BETA" / "NEW" sidebar badges.
pub const BADGE_BETA: Color32 = hex(0xffc14d);
/// 3 px: buttons, inputs, menus and the prompt box.
pub const RADIUS: CornerRadius = CornerRadius::same(3);
/// Height of toolbar buttons (`py-1.5` + 14 px text + hairline).
pub const CONTROL_H: f32 = 34.0;
/// The `.hud-label`: 11 px Geist Mono, upper case, 0.15 em apart.
pub const HUD_SIZE: f32 = 11.0;
pub const HUD_SPACING: f32 = HUD_SIZE * 0.15;
/// Section labels and mono buttons are tracked a little tighter (0.12 em).
pub const LABEL_SPACING: f32 = HUD_SIZE * 0.12;
pub fn hairline() -> Stroke {
Stroke::new(1.0, LINE)
}
/// `c` at `a` (0–1) of its opacity.
pub fn fade(c: Color32, a: f32) -> Color32 {
c.gamma_multiply(a.clamp(0.0, 1.0))
}
/// The shadow under menus, modals and toasts.
pub fn shadow() -> egui::epaint::Shadow {
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(140) }
}
/// Inter 500.
pub fn medium() -> FontFamily {
FontFamily::Name("medium".into())
}
/// Inter 600.
pub fn semibold() -> FontFamily {
FontFamily::Name("semibold".into())
}
/// Archivo 620 at 118 % width: `.font-display` headings.
pub fn display() -> FontFamily {
FontFamily::Name("display".into())
}
/// Geist Mono 500: HUD labels.
pub fn mono() -> FontFamily {
FontFamily::Name("mono".into())
}
/// Geist Mono 600–700: section labels and mono buttons.
pub fn mono_bold() -> FontFamily {
FontFamily::Name("mono-bold".into())
}
fn visuals() -> Visuals {
let mut v = Visuals::dark();
v.panel_fill = BG;
v.window_fill = PANEL;
v.extreme_bg_color = SUNKEN;
v.text_edit_bg_color = Some(Color32::TRANSPARENT);
v.faint_bg_color = CONTROLS;
v.code_bg_color = SUNKEN;
v.window_stroke = hairline();
v.window_corner_radius = RADIUS;
v.menu_corner_radius = RADIUS;
v.window_shadow = shadow();
v.popup_shadow = shadow();
v.override_text_color = Some(INK);
v.hyperlink_color = ACCENT_INK;
v.selection.bg_fill = ACCENT;
v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
v.text_cursor.stroke = Stroke::new(1.5, Color32::WHITE);
v.warn_fg_color = BADGE_BETA;
v.error_fg_color = BAD;
v.striped = false;
for (w, fill, stroke, fg) in [(&mut v.widgets.noninteractive, CONTROLS, LINE, MUTED), (&mut v.widgets.inactive, CONTROLS, LINE, INK), (&mut v.widgets.hovered, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.active, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.open, WASH_HOVER, LINE_STRONG, INK_STRONG)] {
w.bg_fill = fill;
w.weak_bg_fill = fill;
w.bg_stroke = Stroke::new(1.0, stroke);
w.corner_radius = RADIUS;
w.fg_stroke = Stroke::new(1.0, fg);
w.expansion = 0.0;
}
v
}
/// Installs the fonts, colours and spacing. The app is dark only, like the webapp.
pub fn apply(ctx: &egui::Context) {
install_fonts(ctx);
ctx.set_visuals_of(egui::Theme::Dark, visuals());
ctx.set_theme(egui::ThemePreference::Dark);
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(12.0, 6.0);
style.spacing.interact_size.y = CONTROL_H;
style.spacing.menu_margin = egui::Margin::same(4);
style.spacing.window_margin = egui::Margin::same(16);
style.spacing.scroll = egui::style::ScrollStyle { bar_width: 8.0, floating: true, ..egui::style::ScrollStyle::thin() };
style.text_styles = [(TextStyle::Heading, FontId::new(24.0, display())), (TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)), (TextStyle::Button, FontId::new(14.0, medium())), (TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)), (TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace))].into();
});
}
/// A font registered under `name`: its file, its variation coordinates and its family.
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
/// Registers the three variable fonts, each at the weights (and width) the webapp uses.
fn install_fonts(ctx: &egui::Context) {
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Variable.ttf");
const ARCHIVO: &[u8] = include_bytes!("../assets/fonts/Archivo-Variable.ttf");
const GEIST_MONO: &[u8] = include_bytes!("../assets/fonts/GeistMono-Variable.ttf");
let mut fonts = FontDefinitions::default();
// egui's own fonts stay behind ours, for symbols and emoji ours don't have.
let fallback = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let faces: [Face<'_>; 7] = [("Inter", INTER, &[(*b"wght", 400.0), (*b"opsz", 14.0)], FontFamily::Proportional), ("Inter-Medium", INTER, &[(*b"wght", 500.0), (*b"opsz", 14.0)], medium()), ("Inter-SemiBold", INTER, &[(*b"wght", 600.0), (*b"opsz", 14.0)], semibold()), ("Archivo-Display", ARCHIVO, &[(*b"wght", 620.0), (*b"wdth", 118.0)], display()), ("GeistMono", GEIST_MONO, &[(*b"wght", 400.0)], FontFamily::Monospace), ("GeistMono-Medium", GEIST_MONO, &[(*b"wght", 500.0)], mono()), ("GeistMono-SemiBold", GEIST_MONO, &[(*b"wght", 650.0)], mono_bold())];
for (name, bytes, coords, family) in faces {
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak)));
let mut stack = vec![name.to_owned()];
stack.extend(fallback.iter().cloned());
fonts.families.insert(family, stack);
}
ctx.set_fonts(fonts);
}

View File

@ -0,0 +1,73 @@
//! Model makers' logos (the Tauri app's `/resources/images/services` SVGs), rasterized once into
//! white-on-transparent textures so they can be tinted like the webapp's `icon-auto-contrast`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use egui::{ColorImage, TextureHandle, TextureOptions};
/// Rasterized side, in pixels (crisp up to ~32 pt at 2x).
const RASTER_SIDE: u32 = 64;
macro_rules! service_svg {
($name:literal) => {
include_bytes!(concat!("../../../frontend/apps/artcraft/app/public/resources/images/services/", $name, ".svg"))
};
}
/// The logo for a `ModelCreator` (snake_case, as the model listing sends it).
fn svg_for_creator(creator: &str) -> &'static [u8] {
match creator {
"alibaba" => service_svg!("alibaba"),
"artcraft" => service_svg!("artcraft"),
"black_forest_labs" | "blackforestlabs" | "bfl" => service_svg!("blackforestlabs"),
"bytedance" => service_svg!("bytedance"),
"fal" => service_svg!("fal"),
"google" => service_svg!("google"),
"grok" | "xai" => service_svg!("grok"),
"higgsfield" => service_svg!("higgsfield"),
"kling" | "kuaishou" => service_svg!("kling"),
"krea" => service_svg!("krea"),
"midjourney" => service_svg!("midjourney"),
"minimax" | "hailuo" => service_svg!("minimax"),
"open_ai" | "openai" => service_svg!("openai"),
"openart" | "open_art" => service_svg!("openart"),
"recraft" => service_svg!("recraft"),
"replicate" => service_svg!("replicate"),
"suno" => service_svg!("suno"),
"tencent" => service_svg!("tencent"),
"tensor_art" | "tensorart" => service_svg!("tensorart"),
"vidu" => service_svg!("vidu"),
"world_labs" | "worldlabs" => service_svg!("worldlabs"),
_ => service_svg!("generic"),
}
}
#[derive(Clone, Default)]
struct Cache(Arc<Mutex<HashMap<String, TextureHandle>>>);
/// A white logo for `creator`, to be tinted when drawn.
pub fn texture(ctx: &egui::Context, creator: &str) -> TextureHandle {
let cache: Cache = ctx.data_mut(|d| d.get_temp_mut_or_default::<Cache>(egui::Id::new("creator-icons")).clone());
let mut map = cache.0.lock().unwrap_or_else(|e| e.into_inner());
if let Some(texture) = map.get(creator) {
return texture.clone();
}
let image = rasterize_mask(svg_for_creator(creator)).unwrap_or_else(|| ColorImage::filled([1, 1], egui::Color32::TRANSPARENT));
let texture = ctx.load_texture(format!("creator-{creator}"), image, TextureOptions::LINEAR);
map.insert(creator.to_owned(), texture.clone());
texture
}
/// Renders an SVG and keeps only its coverage: every pixel becomes white at the original alpha.
fn rasterize_mask(svg: &[u8]) -> Option<ColorImage> {
let tree = resvg::usvg::Tree::from_data(svg, &resvg::usvg::Options::default()).ok()?;
let size = tree.size();
let scale = RASTER_SIDE as f32 / size.width().max(size.height());
let mut pixmap = resvg::tiny_skia::Pixmap::new(RASTER_SIDE, RASTER_SIDE)?;
let dx = (RASTER_SIDE as f32 - size.width() * scale) / 2.0;
let dy = (RASTER_SIDE as f32 - size.height() * scale) / 2.0;
resvg::render(&tree, resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, dx, dy), &mut pixmap.as_mut());
let pixels = pixmap.pixels().iter().map(|p| egui::Color32::from_white_alpha(p.alpha())).collect();
Some(ColorImage::new([RASTER_SIDE as usize, RASTER_SIDE as usize], pixels))
}

View File

@ -0,0 +1,370 @@
//! Line icons drawn on Lucide's 24-unit grid (the webapp's icon set), so they stay crisp at any
//! size without an icon font.
use egui::{Color32, Painter, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, vec2};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Icon {
AlertCircle,
ArrowLeftRight,
ArrowUp,
Box,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
Coins,
Copy,
Download,
Eraser,
Eye,
ExternalLink,
Film,
Folder,
Globe,
Gem,
Home,
Image,
Info,
LayoutGrid,
Link,
Library,
List,
LogIn,
LogOut,
Maximize,
Music,
PanelLeft,
Pencil,
Play,
Plus,
RotateCw,
Settings,
Sparkles,
SquareCheck,
Trash,
Upload,
Video,
Volume,
VolumeOff,
Wand,
X,
}
/// Maps Lucide's 24-unit grid onto a rect.
struct Grid {
origin: Pos2,
scale: f32,
stroke: Stroke,
}
impl Grid {
fn new(rect: Rect, color: Color32) -> Self {
let side = rect.width().min(rect.height());
let origin = rect.center() - vec2(side, side) / 2.0;
let scale = side / 24.0;
// Lucide's 2-unit stroke, a touch lighter so small icons don't clog.
Self { origin, scale, stroke: Stroke::new((1.75 * scale).max(1.1), color) }
}
fn p(&self, x: f32, y: f32) -> Pos2 {
self.origin + vec2(x, y) * self.scale
}
fn line(&self, painter: &Painter, pts: &[(f32, f32)]) {
let pts: Vec<Pos2> = pts.iter().map(|&(x, y)| self.p(x, y)).collect();
painter.add(Shape::line(pts, self.stroke));
}
fn closed(&self, painter: &Painter, pts: &[(f32, f32)]) {
let pts: Vec<Pos2> = pts.iter().map(|&(x, y)| self.p(x, y)).collect();
painter.add(Shape::closed_line(pts, self.stroke));
}
fn rect(&self, painter: &Painter, x: f32, y: f32, w: f32, h: f32, r: f32) {
let rect = Rect::from_min_size(self.p(x, y), vec2(w, h) * self.scale);
painter.rect_stroke(rect, r * self.scale, self.stroke, egui::StrokeKind::Middle);
}
fn circle(&self, painter: &Painter, x: f32, y: f32, r: f32) {
painter.circle_stroke(self.p(x, y), r * self.scale, self.stroke);
}
fn dot(&self, painter: &Painter, x: f32, y: f32, r: f32) {
painter.circle_filled(self.p(x, y), r * self.scale, self.stroke.color);
}
/// An arc round (cx, cy), angles in degrees clockwise from 3 o'clock.
fn arc(&self, painter: &Painter, cx: f32, cy: f32, r: f32, from: f32, to: f32) {
let steps = 24;
let pts: Vec<Pos2> = (0..=steps)
.map(|i| {
let a = (from + (to - from) * i as f32 / steps as f32).to_radians();
self.p(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
painter.add(Shape::line(pts, self.stroke));
}
}
/// Paints `icon` centred in `rect`.
pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
let g = Grid::new(rect, color);
let p = painter;
match icon {
Icon::AlertCircle => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(12.0, 8.0), (12.0, 12.0)]);
g.dot(p, 12.0, 16.0, 1.1);
},
Icon::ArrowLeftRight => {
g.line(p, &[(8.0, 3.0), (4.0, 7.0), (8.0, 11.0)]);
g.line(p, &[(4.0, 7.0), (20.0, 7.0)]);
g.line(p, &[(16.0, 21.0), (20.0, 17.0), (16.0, 13.0)]);
g.line(p, &[(20.0, 17.0), (4.0, 17.0)]);
},
Icon::ArrowUp => {
g.line(p, &[(5.0, 12.0), (12.0, 5.0), (19.0, 12.0)]);
g.line(p, &[(12.0, 19.0), (12.0, 5.0)]);
},
Icon::Box => {
g.closed(p, &[(12.0, 2.0), (21.0, 7.0), (21.0, 17.0), (12.0, 22.0), (3.0, 17.0), (3.0, 7.0)]);
g.line(p, &[(3.0, 7.0), (12.0, 12.0), (21.0, 7.0)]);
g.line(p, &[(12.0, 12.0), (12.0, 22.0)]);
},
Icon::Check => g.line(p, &[(20.0, 6.0), (9.0, 17.0), (4.0, 12.0)]),
Icon::ChevronDown => g.line(p, &[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)]),
Icon::ChevronLeft => g.line(p, &[(15.0, 18.0), (9.0, 12.0), (15.0, 6.0)]),
Icon::ChevronRight => g.line(p, &[(9.0, 18.0), (15.0, 12.0), (9.0, 6.0)]),
Icon::ChevronUp => g.line(p, &[(18.0, 15.0), (12.0, 9.0), (6.0, 15.0)]),
Icon::Clock => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(12.0, 6.0), (12.0, 12.0), (16.0, 14.0)]);
},
Icon::Coins => {
g.circle(p, 8.0, 8.0, 6.0);
g.arc(p, 15.5, 15.5, 6.0, -100.0, 190.0);
g.line(p, &[(7.0, 6.0), (8.0, 5.0), (8.0, 11.0)]);
},
Icon::Copy => {
g.rect(p, 8.0, 8.0, 14.0, 14.0, 2.0);
g.line(p, &[(4.0, 16.0), (4.0, 4.0), (16.0, 4.0)]);
},
Icon::Download => {
g.line(p, &[(12.0, 3.0), (12.0, 15.0)]);
g.line(p, &[(7.0, 10.0), (12.0, 15.0), (17.0, 10.0)]);
g.line(p, &[(4.0, 21.0), (20.0, 21.0)]);
},
Icon::Eraser => {
g.closed(p, &[(7.0, 21.0), (3.5, 17.5), (14.0, 7.0), (21.0, 14.0), (14.0, 21.0)]);
g.line(p, &[(22.0, 21.0), (7.0, 21.0)]);
g.line(p, &[(9.0, 12.0), (16.0, 19.0)]);
},
Icon::ExternalLink => {
g.line(p, &[(15.0, 3.0), (21.0, 3.0), (21.0, 9.0)]);
g.line(p, &[(10.0, 14.0), (21.0, 3.0)]);
g.line(p, &[(18.0, 13.0), (18.0, 19.0), (16.0, 21.0), (5.0, 21.0), (3.0, 19.0), (3.0, 8.0), (5.0, 6.0), (11.0, 6.0)]);
},
Icon::Eye => {
g.closed(p, &[(2.0, 12.0), (5.0, 7.5), (9.0, 5.2), (12.0, 4.8), (15.0, 5.2), (19.0, 7.5), (22.0, 12.0), (19.0, 16.5), (15.0, 18.8), (12.0, 19.2), (9.0, 18.8), (5.0, 16.5)]);
g.circle(p, 12.0, 12.0, 3.0);
},
Icon::Gem => {
g.closed(p, &[(6.0, 3.0), (18.0, 3.0), (22.0, 9.0), (12.0, 22.0), (2.0, 9.0)]);
g.line(p, &[(2.0, 9.0), (22.0, 9.0)]);
g.line(p, &[(11.0, 3.0), (8.0, 9.0), (12.0, 22.0), (16.0, 9.0), (13.0, 3.0)]);
},
Icon::Info => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(12.0, 16.0), (12.0, 12.0)]);
g.dot(p, 12.0, 8.0, 1.1);
},
Icon::Link => {
g.line(p, &[(10.0, 13.0), (11.0, 14.0), (14.0, 14.4), (16.5, 13.0), (20.0, 9.5), (20.6, 6.0), (18.0, 3.4), (14.5, 4.0), (12.8, 5.7)]);
g.line(p, &[(14.0, 11.0), (13.0, 10.0), (10.0, 9.6), (7.5, 11.0), (4.0, 14.5), (3.4, 18.0), (6.0, 20.6), (9.5, 20.0), (11.2, 18.3)]);
},
Icon::LogIn => {
g.line(p, &[(15.0, 3.0), (19.0, 3.0), (21.0, 5.0), (21.0, 19.0), (19.0, 21.0), (15.0, 21.0)]);
g.line(p, &[(10.0, 17.0), (15.0, 12.0), (10.0, 7.0)]);
g.line(p, &[(15.0, 12.0), (3.0, 12.0)]);
},
Icon::SquareCheck => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.line(p, &[(8.0, 12.0), (11.0, 15.0), (16.0, 9.0)]);
},
Icon::Film => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.line(p, &[(7.0, 3.0), (7.0, 21.0)]);
g.line(p, &[(17.0, 3.0), (17.0, 21.0)]);
g.line(p, &[(3.0, 12.0), (21.0, 12.0)]);
g.line(p, &[(3.0, 7.5), (7.0, 7.5)]);
g.line(p, &[(17.0, 7.5), (21.0, 7.5)]);
g.line(p, &[(3.0, 16.5), (7.0, 16.5)]);
g.line(p, &[(17.0, 16.5), (21.0, 16.5)]);
},
Icon::Folder => g.closed(p, &[(2.0, 5.0), (3.0, 4.0), (9.0, 4.0), (11.0, 7.0), (21.0, 7.0), (22.0, 8.0), (22.0, 19.0), (21.0, 20.0), (3.0, 20.0), (2.0, 19.0)]),
Icon::Globe => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(2.0, 12.0), (22.0, 12.0)]);
g.arc(p, 2.0, 12.0, 14.14, -45.0, 45.0);
g.arc(p, 22.0, 12.0, 14.14, 135.0, 225.0);
},
Icon::Home => {
g.closed(p, &[(3.0, 10.0), (12.0, 3.0), (21.0, 10.0), (21.0, 21.0), (3.0, 21.0)]);
g.line(p, &[(9.0, 21.0), (9.0, 14.0), (15.0, 14.0), (15.0, 21.0)]);
},
Icon::Image => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.circle(p, 9.0, 9.0, 2.0);
g.line(p, &[(21.0, 15.0), (16.0, 10.0), (5.0, 21.0)]);
},
Icon::LayoutGrid => {
for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] {
g.rect(p, x, y, 7.0, 7.0, 1.0);
}
},
Icon::Library => {
g.line(p, &[(16.0, 6.0), (20.0, 20.0)]);
g.line(p, &[(12.0, 6.0), (12.0, 20.0)]);
g.line(p, &[(8.0, 8.0), (8.0, 20.0)]);
g.line(p, &[(4.0, 4.0), (4.0, 20.0)]);
},
Icon::List => {
for y in [6.0, 12.0, 18.0] {
g.line(p, &[(8.0, y), (21.0, y)]);
g.dot(p, 3.5, y, 1.1);
}
},
Icon::LogOut => {
g.line(p, &[(9.0, 21.0), (5.0, 21.0), (3.0, 19.0), (3.0, 5.0), (5.0, 3.0), (9.0, 3.0)]);
g.line(p, &[(16.0, 17.0), (21.0, 12.0), (16.0, 7.0)]);
g.line(p, &[(21.0, 12.0), (9.0, 12.0)]);
},
Icon::Maximize => {
g.line(p, &[(15.0, 3.0), (21.0, 3.0), (21.0, 9.0)]);
g.line(p, &[(9.0, 21.0), (3.0, 21.0), (3.0, 15.0)]);
g.line(p, &[(21.0, 3.0), (14.0, 10.0)]);
g.line(p, &[(3.0, 21.0), (10.0, 14.0)]);
},
Icon::Music => {
g.line(p, &[(9.0, 18.0), (9.0, 5.0), (21.0, 3.0), (21.0, 16.0)]);
g.circle(p, 6.0, 18.0, 3.0);
g.circle(p, 18.0, 16.0, 3.0);
},
Icon::PanelLeft => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.line(p, &[(9.0, 3.0), (9.0, 21.0)]);
},
Icon::Pencil => {
g.closed(p, &[(17.0, 3.0), (21.0, 7.0), (7.5, 20.5), (2.0, 22.0), (3.5, 16.5)]);
g.line(p, &[(15.0, 5.0), (19.0, 9.0)]);
},
Icon::Play => {
let pts = [g.p(7.0, 4.0), g.p(20.0, 12.0), g.p(7.0, 20.0)];
p.add(Shape::convex_polygon(pts.to_vec(), color, Stroke::NONE));
},
Icon::Plus => {
g.line(p, &[(5.0, 12.0), (19.0, 12.0)]);
g.line(p, &[(12.0, 5.0), (12.0, 19.0)]);
},
Icon::RotateCw => {
g.arc(p, 12.0, 12.0, 9.0, -60.0, 250.0);
g.line(p, &[(21.0, 3.0), (21.0, 8.5), (15.5, 8.5)]);
},
Icon::Settings => {
g.circle(p, 12.0, 12.0, 3.0);
g.circle(p, 12.0, 12.0, 7.5);
for i in 0..8 {
let a = (i as f32 * 45.0).to_radians();
g.line(p, &[(12.0 + 7.5 * a.cos(), 12.0 + 7.5 * a.sin()), (12.0 + 10.0 * a.cos(), 12.0 + 10.0 * a.sin())]);
}
},
Icon::Sparkles => {
let star = |cx: f32, cy: f32, r: f32| {
let k = r * 0.28;
g.closed(p, &[(cx, cy - r), (cx + k, cy - k), (cx + r, cy), (cx + k, cy + k), (cx, cy + r), (cx - k, cy + k), (cx - r, cy), (cx - k, cy - k)]);
};
star(10.0, 12.0, 8.0);
star(19.0, 5.0, 3.0);
},
Icon::Trash => {
g.line(p, &[(3.0, 6.0), (21.0, 6.0)]);
g.line(p, &[(19.0, 6.0), (19.0, 20.0), (17.0, 22.0), (7.0, 22.0), (5.0, 20.0), (5.0, 6.0)]);
g.line(p, &[(8.0, 6.0), (8.0, 4.0), (10.0, 2.0), (14.0, 2.0), (16.0, 4.0), (16.0, 6.0)]);
},
Icon::Upload => {
g.line(p, &[(12.0, 15.0), (12.0, 3.0)]);
g.line(p, &[(7.0, 8.0), (12.0, 3.0), (17.0, 8.0)]);
g.line(p, &[(4.0, 21.0), (20.0, 21.0)]);
},
Icon::Video => {
g.rect(p, 2.0, 6.0, 14.0, 12.0, 2.0);
g.closed(p, &[(16.0, 10.5), (22.0, 7.0), (22.0, 17.0), (16.0, 13.5)]);
},
Icon::Volume | Icon::VolumeOff => {
g.closed(p, &[(11.0, 5.0), (6.0, 9.0), (2.0, 9.0), (2.0, 15.0), (6.0, 15.0), (11.0, 19.0)]);
if icon == Icon::Volume {
g.arc(p, 12.0, 12.0, 4.0, -50.0, 50.0);
g.arc(p, 12.0, 12.0, 8.0, -50.0, 50.0);
} else {
g.line(p, &[(16.0, 9.0), (22.0, 15.0)]);
g.line(p, &[(22.0, 9.0), (16.0, 15.0)]);
}
},
Icon::Wand => {
g.line(p, &[(3.0, 21.0), (15.0, 9.0)]);
g.line(p, &[(15.0, 4.0), (15.0, 6.0)]);
g.line(p, &[(18.0, 9.0), (20.0, 9.0)]);
g.line(p, &[(18.5, 5.5), (17.0, 7.0)]);
g.line(p, &[(11.0, 5.0), (12.0, 6.0)]);
g.line(p, &[(19.0, 13.0), (18.0, 12.0)]);
},
Icon::X => {
g.line(p, &[(18.0, 6.0), (6.0, 18.0)]);
g.line(p, &[(6.0, 6.0), (18.0, 18.0)]);
},
}
}
/// Allocates a `size` square and paints `icon` in it.
pub fn icon(ui: &mut Ui, icon: Icon, size: f32, color: Color32) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
paint(ui.painter(), rect, icon, color);
resp
}
/// The outline of an aspect ratio `w:h`, fitted inside `rect` (the webapp's `AspectRatioIcon`).
/// `None` draws the dashed "auto" frame.
pub fn paint_aspect(painter: &Painter, rect: Rect, ratio: Option<(f32, f32)>, color: Color32) {
let side = rect.width().min(rect.height()) * 0.8;
let stroke = Stroke::new(1.4, color);
match ratio {
Some((w, h)) if w > 0.0 && h > 0.0 => {
let size = if w >= h { vec2(side, side * h / w) } else { vec2(side * w / h, side) };
let r = Rect::from_center_size(rect.center(), size);
painter.rect_stroke(r, 1.5, stroke, egui::StrokeKind::Middle);
},
_ => {
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let shapes = Shape::dashed_line(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()], stroke, 2.5, 2.0);
painter.extend(shapes);
},
}
}
/// A spinner arc, like Lucide's `LoaderCircle` with `animate-spin`.
pub fn paint_spinner(ui: &Ui, rect: Rect, color: Color32) {
let t = ui.input(|i| i.time) as f32;
let g = Grid::new(rect, color);
let start = t * 360.0;
g.arc(ui.painter(), 12.0, 12.0, 9.0, start, start + 270.0);
ui.ctx().request_repaint();
}
/// A dashed outline round `rect` (drop targets and empty reference slots).
pub fn dashed_rect(painter: &Painter, rect: Rect, stroke: Stroke, dash: f32, gap: f32) {
let pts = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom(), rect.left_top()];
painter.extend(Shape::dashed_line(&pts, stroke, dash, gap));
}

View File

@ -0,0 +1,5 @@
pub mod creator_icons;
pub mod icons;
pub mod toast;
pub mod widgets;
pub mod window;

View File

@ -0,0 +1,97 @@
//! Toasts: brief success and error notes stacked at the top right (the webapp's `toast`).
use std::time::Duration;
use egui::{Align2, Color32, FontId, Id, Order, Rect, Sense, pos2, vec2};
use crate::theme;
use crate::ui::icons::{self, Icon};
const DEFAULT_DURATION: Duration = Duration::from_secs(4);
const MAX_WIDTH: f32 = 448.0;
/// Below the title bar and the panel header (the webapp's `top-16 right-5`, shifted).
const TOP_OFFSET: f32 = 100.0;
const RIGHT_OFFSET: f32 = 20.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastKind {
Success,
Error,
}
struct Toast {
id: u64,
kind: ToastKind,
message: String,
expires_at: f64,
}
#[derive(Default)]
pub struct Toasts {
next_id: u64,
items: Vec<Toast>,
pending: Vec<(ToastKind, String)>,
}
impl Toasts {
pub fn success(&mut self, message: impl Into<String>) {
self.pending.push((ToastKind::Success, message.into()));
}
pub fn error(&mut self, message: impl Into<String>) {
self.pending.push((ToastKind::Error, message.into()));
}
/// Draws the stack over everything else and drops expired toasts.
pub fn show(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
for (kind, message) in self.pending.drain(..) {
// The same message twice in a row (e.g. a held Enter key) shows once, with a fresh timer.
self.items.retain(|t| !(t.kind == kind && t.message == message));
self.next_id += 1;
self.items.push(Toast { id: self.next_id, kind, message, expires_at: now + DEFAULT_DURATION.as_secs_f64() });
}
self.items.retain(|t| t.expires_at > now);
if self.items.is_empty() {
return;
}
let screen = ctx.content_rect();
let mut y = screen.top() + TOP_OFFSET;
let mut dismissed = None;
for toast in &self.items {
let area = egui::Area::new(Id::new(("toast", toast.id))).order(Order::Tooltip).pivot(Align2::RIGHT_TOP).fixed_pos(pos2(screen.right() - RIGHT_OFFSET, y)).interactable(true);
let inner = area.show(ctx, |ui| {
let galley = ui.painter().layout(toast.message.clone(), FontId::new(14.0, egui::FontFamily::Proportional), theme::fade(Color32::WHITE, 0.9), MAX_WIDTH - 80.0);
let h = galley.size().y.max(24.0) + 20.0;
let w = 16.0 + 24.0 + 12.0 + galley.size().x + 12.0 + 12.0 + 16.0;
let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
let p = ui.painter();
p.rect(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.97), theme::hairline(), egui::StrokeKind::Inside);
let badge = Rect::from_min_size(pos2(rect.left() + 16.0, rect.center().y - 12.0), vec2(24.0, 24.0));
let (fill, icon) = match toast.kind {
ToastKind::Success => (Color32::from_rgb(0x22, 0xc5, 0x5e), Icon::Check),
ToastKind::Error => (Color32::from_rgb(0xef, 0x44, 0x44), Icon::AlertCircle),
};
p.rect_filled(badge, 0.0, fill);
icons::paint(p, badge.shrink(6.0), icon, Color32::WHITE);
p.galley(pos2(badge.right() + 12.0, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
let close = Rect::from_center_size(pos2(rect.right() - 22.0, rect.center().y), vec2(16.0, 16.0));
let close_resp = ui.interact(close, Id::new(("toast-close", toast.id)), Sense::click());
let ink = if close_resp.hovered() { theme::fade(Color32::WHITE, 0.8) } else { theme::fade(Color32::WHITE, 0.4) };
icons::paint(p, close.shrink(2.0), Icon::X, ink);
close_resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
});
if inner.inner {
dismissed = Some(toast.id);
}
y += inner.response.rect.height() + 8.0;
}
if let Some(id) = dismissed {
self.items.retain(|t| t.id != id);
}
// Wake up to expire the oldest toast on time.
if let Some(next) = self.items.iter().map(|t| t.expires_at).reduce(f64::min) {
ctx.request_repaint_after(Duration::from_secs_f64((next - now).max(0.0)));
}
}
}

View File

@ -0,0 +1,354 @@
//! Building blocks in the webapp's brutalist style: mono HUD labels, 3 px toolbar pills, the round
//! generate button, popover menus and corner registration marks.
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, FontId, Frame, Galley, Margin, Painter, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use crate::theme;
use crate::ui::icons::{self, Icon};
/// `text` laid out upper case in `family`, `spacing` points between letters.
pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc<Galley> {
let format = egui::TextFormat { font_id: FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format))
}
/// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced.
pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color);
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
ui.painter().galley(rect.min, galley, color);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
resp
}
/// A sidebar section or menu heading: 11 px Geist Mono 600, upper case.
pub fn section_label(ui: &mut Ui, text: &str, color: Color32) -> Response {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, color);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
let pos = pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0);
ui.painter().galley(pos, galley, color);
resp
}
/// A small badge after a sidebar link ("BETA", "NEW"): 9 px mono in `color` on a tinted chip.
pub fn badge(painter: &Painter, ui: &Ui, right_center: egui::Pos2, text: &str, color: Color32) {
let galley = caps(ui, text, 9.0, theme::mono_bold(), 0.9, color);
let size = galley.size() + vec2(10.0, 4.0);
let rect = Rect::from_min_size(pos2(right_center.x - size.x, right_center.y - size.y / 2.0), size);
painter.rect(rect, CornerRadius::same(2), theme::fade(color, 0.12), Stroke::new(1.0, theme::fade(color, 0.4)), egui::StrokeKind::Inside);
painter.galley(rect.center() - galley.size() / 2.0, galley, color);
}
/// What a [`pill`] shows before its label.
pub enum Leading<'a> {
None,
Icon(Icon),
/// An aspect ratio outline (`None` = the dashed "auto" frame).
Aspect(Option<(f32, f32)>),
/// A small caption in front of the label ("SD", "HD").
Caption(&'a str),
/// A model maker's logo.
Logo(&'a egui::TextureHandle),
}
/// A toolbar pill (`bg-ui-controls border-white/15 rounded-[3px] px-3 py-1.5 text-sm font-medium`),
/// as used for the model, aspect ratio, resolution and duration pickers.
pub fn pill(ui: &mut Ui, leading: Leading<'_>, label: &str, open: bool) -> Response {
let enabled = ui.is_enabled();
let font = FontId::new(14.0, theme::medium());
let galley = ui.painter().layout_no_wrap(label.to_owned(), font, theme::INK);
let caption = match leading {
Leading::Caption(c) => Some(caps(ui, c, 9.0, theme::mono_bold(), 0.6, theme::DIM)),
_ => None,
};
let lead_w = match (&leading, &caption) {
(Leading::None, _) => 0.0,
(_, Some(c)) => c.size().x + 6.0,
_ => 14.0 + 7.0,
};
let size = vec2(12.0 + lead_w + galley.size().x + 12.0, theme::CONTROL_H);
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
let hovered = resp.hovered() && enabled;
let p = ui.painter();
let fill = if open || hovered { blend(theme::CONTROLS, theme::WASH_HOVER) } else { theme::CONTROLS };
let stroke = if open { theme::LINE_STRONG } else { theme::LINE };
p.rect(rect, theme::RADIUS, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
let ink = if enabled { theme::INK } else { theme::FAINT };
let mut x = rect.left() + 12.0;
let lead_rect = Rect::from_center_size(pos2(x + 7.0, rect.center().y), vec2(14.0, 14.0));
match leading {
Leading::None => {},
Leading::Icon(icon) => icons::paint(p, lead_rect, icon, ink),
Leading::Aspect(ratio) => icons::paint_aspect(p, lead_rect, ratio, ink),
Leading::Logo(texture) => {
egui::Image::new(texture).tint(ink).paint_at(ui, lead_rect);
},
Leading::Caption(_) => {
if let Some(c) = caption {
p.galley(pos2(x, rect.center().y - c.size().y / 2.0), c, theme::DIM);
}
},
}
x += lead_w;
p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, ink);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// A transparent toolbar button with an icon and an optional trailing label
/// (`text-base-fg/50`, `hover:bg-white/5`): the count picker and clear-all.
pub fn ghost(ui: &mut Ui, icon: Icon, label: Option<&str>, tip: &str, active: bool) -> Response {
let enabled = ui.is_enabled();
let galley = label.map(|l| ui.painter().layout_no_wrap(l.to_owned(), FontId::new(14.0, theme::medium()), theme::INK));
let w = match &galley {
Some(g) => 10.0 + 15.0 + 6.0 + g.size().x + 10.0,
None => 36.0,
};
let (rect, resp) = ui.allocate_exact_size(vec2(w, 36.0), Sense::click());
let hovered = resp.hovered() && enabled;
let p = ui.painter();
if hovered || active {
p.rect_filled(rect, theme::RADIUS, theme::WASH);
}
let ink = match (enabled, hovered || active) {
(false, _) => theme::fade(theme::DIM, 0.5),
(true, true) => theme::INK,
(true, false) => theme::DIM,
};
match galley {
Some(g) => {
let icon_rect = Rect::from_center_size(pos2(rect.left() + 10.0 + 7.5, rect.center().y), vec2(15.0, 15.0));
icons::paint(p, icon_rect, icon, ink);
p.galley_with_override_text_color(pos2(icon_rect.right() + 6.0, rect.center().y - g.size().y / 2.0), g, ink);
},
None => icons::paint(p, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, ink),
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
let resp = resp.on_hover_text(tip);
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// A small square icon button for overlays (`h-7 w-7`, `hover:bg-white/15`).
pub fn overlay_icon(ui: &mut Ui, icon: Icon, tip: &str) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(rect, theme::RADIUS, Color32::from_white_alpha(38));
}
let ink = if hovered { Color32::WHITE } else { Color32::from_white_alpha(217) };
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(14.0, 14.0)), icon, ink);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The round blue generate button (`GenerateIconButton`): an up arrow, a spinner while `loading`.
pub fn generate_button(ui: &mut Ui, enabled: bool, loading: bool, tip: &str) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), if enabled { Sense::click() } else { Sense::hover() });
let hovered = resp.hovered() && enabled;
let fill = match (enabled, hovered) {
(false, _) => theme::fade(theme::ACCENT, 0.4),
(true, true) => theme::ACCENT_400,
(true, false) => theme::ACCENT,
};
ui.painter().circle_filled(rect.center(), 18.0, fill);
let ink = if enabled { Color32::WHITE } else { theme::fade(Color32::WHITE, 0.5) };
let inner = Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
if loading {
icons::paint_spinner(ui, inner, ink);
} else {
icons::paint(ui.painter(), inner, Icon::ArrowUp, ink);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, "Generate"));
let resp = resp.on_hover_text(tip);
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// Which of the site's block button variants.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// A white block with near-black text; fades a little on hover.
Primary,
/// Outlined in `base-fg/40`; inverts on hover.
Secondary,
/// The brand blue.
Accent,
/// Solid red.
Danger,
}
/// A block button with a mono upper-case label (`SIGN UP TO CREATE`, `DOWNLOAD`), at least `width`
/// wide and `height` tall.
pub fn button(ui: &mut Ui, icon: Option<Icon>, text: &str, kind: Kind, width: f32, height: f32) -> Response {
let enabled = ui.is_enabled();
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::INK);
let icon_w = if icon.is_some() { 13.0 + 8.0 } else { 0.0 };
let size = vec2((galley.size().x + icon_w + 40.0).max(width), height);
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
let hovered = resp.hovered() && enabled;
let (fill, stroke, ink) = match kind {
Kind::Primary => (theme::fade(theme::INVERT_BG, if hovered { 0.88 } else { 1.0 }), Stroke::NONE, theme::INVERT_FG),
Kind::Accent => (if hovered { theme::ACCENT_400 } else { theme::ACCENT }, Stroke::NONE, Color32::WHITE),
Kind::Danger => (theme::fade(theme::DANGER, if hovered { 0.85 } else { 1.0 }), Stroke::NONE, Color32::WHITE),
Kind::Secondary if hovered => (theme::INVERT_BG, Stroke::NONE, theme::INVERT_FG),
Kind::Secondary => (Color32::TRANSPARENT, Stroke::new(1.0, theme::LINE_STRONG), theme::INK),
};
let dim = |c: Color32| if enabled { c } else { theme::fade(c, 0.5) };
let p = ui.painter();
p.rect(rect, theme::RADIUS, dim(fill), Stroke::new(stroke.width, dim(stroke.color)), egui::StrokeKind::Inside);
let content_w = icon_w + galley.size().x;
let mut x = rect.center().x - content_w / 2.0;
if let Some(icon) = icon {
icons::paint(p, Rect::from_center_size(pos2(x + 6.5, rect.center().y), vec2(13.0, 13.0)), icon, dim(ink));
x += icon_w;
}
p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, dim(ink));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, text));
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// Fine registration marks at a panel's corners (the landing page's section corners).
pub fn corner_marks(painter: &Painter, rect: Rect) {
let stroke = Stroke::new(1.0, theme::LINE_STRONG);
for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] {
painter.hline(c.x - 5.5..=c.x + 5.5, c.y, stroke);
painter.vline(c.x, c.y - 5.5..=c.y + 5.5, stroke);
}
}
/// The frame of popovers and menus: the controls surface, a hairline and a soft shadow.
pub fn popover_frame() -> Frame {
Frame::new().fill(theme::CONTROLS).stroke(theme::hairline()).corner_radius(theme::RADIUS).shadow(theme::shadow()).inner_margin(Margin::same(4))
}
/// A popover's heading ("SELECT MODEL", "ASPECT RATIO") with a hairline under it.
pub fn menu_header(ui: &mut Ui, text: &str) {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::fade(theme::INK, 0.5));
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
ui.painter().galley(pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK);
}
/// What a [`menu_row`] shows at its left.
pub enum RowLead<'a> {
None,
Icon(Icon),
Aspect(Option<(f32, f32)>),
/// A 36 px tile with a model maker's logo (white texture, tinted).
Logo(&'a egui::TextureHandle),
}
/// One option in a popover: a lead, a title, an optional muted subtitle, and a check when
/// `selected` (or a chevron when `submenu`). Highlights on hover or when `highlighted`.
pub fn menu_row(ui: &mut Ui, lead: RowLead<'_>, title: &str, subtitle: Option<&str>, selected: bool, submenu: bool, highlighted: bool) -> Response {
let enabled = ui.is_enabled();
let h = if subtitle.is_some() { 48.0 } else { 34.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
let on = (resp.hovered() && enabled) || highlighted;
let p = ui.painter();
if on {
p.rect_filled(rect, theme::RADIUS, theme::WASH_HOVER);
}
let ink = if enabled { theme::INK } else { theme::FAINT };
let mut x = rect.left() + 8.0;
match lead {
RowLead::None => {},
RowLead::Icon(icon) => {
icons::paint(p, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(15.0, 15.0)), icon, ink);
x += 26.0;
},
RowLead::Aspect(ratio) => {
icons::paint_aspect(p, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(16.0, 16.0)), ratio, ink);
x += 26.0;
},
RowLead::Logo(texture) => {
let tile = Rect::from_center_size(pos2(x + 18.0, rect.center().y), vec2(36.0, 36.0));
let (fill, border) = if selected { (theme::WASH_HOVER, Color32::WHITE) } else { (theme::WASH, theme::LINE) };
p.rect(tile, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside);
egui::Image::new(texture).tint(ink).paint_at(ui, Rect::from_center_size(tile.center(), vec2(18.0, 18.0)));
x += 46.0;
},
}
let title_font = FontId::new(13.5, theme::medium());
match subtitle {
Some(sub) => {
p.text(pos2(x, rect.center().y - 8.0), Align2::LEFT_CENTER, title, title_font, ink);
p.text(pos2(x, rect.center().y + 8.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED);
},
None => {
p.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, title, title_font, ink);
},
}
let trail = Rect::from_center_size(pos2(rect.right() - 14.0, rect.center().y), vec2(13.0, 13.0));
if selected {
icons::paint(p, trail, Icon::Check, theme::INK);
} else if submenu {
icons::paint(p, trail, Icon::ChevronRight, theme::DIM);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, title));
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// Paints `fg` over `bg` (for hover fills on opaque surfaces).
pub fn blend(bg: Color32, fg: Color32) -> Color32 {
let a = fg.a() as f32 / 255.0;
let mix = |b: u8, f: u8| (b as f32 * (1.0 - a) + f as f32 * a).round() as u8;
let fg = fg.to_srgba_unmultiplied();
Color32::from_rgb(mix(bg.r(), fg[0]), mix(bg.g(), fg[1]), mix(bg.b(), fg[2]))
}
/// A thin square progress bar (`h-1 bg-white/10`, filled in `primary-400`).
pub fn progress_bar(painter: &Painter, rect: Rect, fraction: f32) {
painter.rect_filled(rect, CornerRadius::same(2), theme::WASH_HOVER);
let fill = Rect::from_min_size(rect.min, vec2(rect.width() * fraction.clamp(0.0, 1.0), rect.height()));
painter.rect_filled(fill, CornerRadius::same(2), theme::ACCENT_400);
}
/// A heading in the display face (Archivo 620, tracked in like the site's `tracking-[-0.03em]`).
pub fn heading(text: &str, size: f32) -> egui::RichText {
egui::RichText::new(text).size(size).family(theme::display()).color(theme::INK).extra_letter_spacing(-0.03 * size)
}
/// A square modal on the panel surface with a dimmed backdrop. Returns whether it asked to close
/// (backdrop click or Escape).
pub fn modal(ctx: &egui::Context, id: egui::Id, width: f32, content: impl FnOnce(&mut Ui)) -> bool {
let frame = Frame::new().fill(theme::PANEL).stroke(theme::hairline()).inner_margin(Margin::same(16)).shadow(theme::shadow());
let resp = egui::Modal::new(id).frame(frame).backdrop_color(Color32::from_black_alpha(153)).show(ctx, |ui| {
ui.set_width(width.min(ctx.content_rect().width() - 48.0));
content(ui);
});
resp.should_close()
}
/// A mono link with an ↗: muted, brighter on hover. Opens `url` in the browser.
pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response {
let galley = caps(ui, &format!("{text} \u{2197}"), theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::MUTED);
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
let color = if resp.hovered() { theme::INK_STRONG } else { theme::MUTED };
ui.painter().galley_with_override_text_color(rect.min, galley, color);
let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
resp
}

View File

@ -0,0 +1,155 @@
//! Window chrome for the undecorated window: dragging and maximizing from the title bar, the
//! Windows-style caption buttons, and resizing from the edges.
use egui::{Color32, Rect, Response, Sense, Stroke, Ui, vec2};
use crate::theme;
/// How far in from the window's edges a drag resizes. Title bar controls leave this band alone.
const RESIZE_EDGE: f32 = 5.0;
/// Caption buttons (Windows' own are 46 × the title bar height).
const BUTTON_WIDTH: f32 = 46.0;
/// The close button's hover red (Windows').
const CLOSE_RED: Color32 = Color32::from_rgb(0xc4, 0x2b, 0x1c);
/// Whether this platform draws its own title bar buttons (macOS keeps the traffic lights).
pub fn custom_caption_buttons() -> bool {
!cfg!(target_os = "macos")
}
/// Space to leave at the title bar's left for macOS's traffic lights.
pub fn leading_inset() -> f32 {
if cfg!(target_os = "macos") {
76.0
} else {
0.0
}
}
/// Dragging the title bar's empty space moves the window; double-clicking maximizes or restores.
/// Call before drawing the title bar's controls so they stay on top.
pub fn title_bar_drag(ui: &mut Ui) {
let r = ui.max_rect();
let rect = Rect::from_min_max(r.min + vec2(RESIZE_EDGE, RESIZE_EDGE), r.max - vec2(RESIZE_EDGE, 0.0));
let resp = ui.interact(rect, ui.id().with("title-bar-drag"), Sense::click_and_drag());
if resp.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if resp.double_clicked() {
let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Caption {
Minimize,
Maximize,
Restore,
Close,
}
/// Minimize, maximize (or restore) and close, `height` tall, for a right-to-left layout so close
/// ends up at the right edge.
pub fn caption_buttons(ui: &mut Ui, height: f32) {
let ctx = ui.ctx().clone();
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
if caption_button(ui, Caption::Close, height).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
let size = if maximized { Caption::Restore } else { Caption::Maximize };
if caption_button(ui, size, height).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}
if caption_button(ui, Caption::Minimize, height).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
}
fn caption_button(ui: &mut Ui, button: Caption, height: f32) -> Response {
let (rect, _) = ui.allocate_exact_size(vec2(BUTTON_WIDTH, height), Sense::hover());
// Clickable below the resize band along the top (and, for close, the right edge).
let right = if button == Caption::Close { RESIZE_EDGE } else { 0.0 };
let hit = Rect::from_min_max(rect.min + vec2(0.0, RESIZE_EDGE), rect.max - vec2(right, 0.0));
let resp = ui.interact(hit, ui.id().with(("caption", button as u8)), Sense::click());
let (fill, ink) = match (button, resp.hovered()) {
(Caption::Close, true) => (CLOSE_RED, Color32::WHITE),
(_, true) => (theme::WASH_HOVER, theme::INK_STRONG),
(_, false) => (Color32::TRANSPARENT, theme::MUTED),
};
let p = ui.painter();
p.rect_filled(rect, 0.0, fill);
let c = rect.center();
let stroke = Stroke::new(1.0, ink);
// Half the glyph's size: 10 px, like Windows' own caption buttons.
let s = 5.0;
let label = match button {
Caption::Minimize => {
p.hline(c.x - s..=c.x + s, c.y, stroke);
"Minimize"
},
Caption::Maximize => {
p.rect_stroke(Rect::from_center_size(c, vec2(2.0 * s, 2.0 * s)), 0.0, stroke, egui::StrokeKind::Inside);
"Maximize"
},
Caption::Restore => {
// A window in front of another.
let front = Rect::from_min_size(c + vec2(-s, -s + 2.0), vec2(2.0 * s - 2.0, 2.0 * s - 2.0));
p.rect_stroke(front, 0.0, stroke, egui::StrokeKind::Inside);
p.hline(c.x - s + 2.0..=c.x + s, c.y - s, stroke);
p.vline(c.x + s, c.y - s..=c.y + s - 2.0, stroke);
"Restore"
},
Caption::Close => {
p.line_segment([c + vec2(-s, -s), c + vec2(s, s)], stroke);
p.line_segment([c + vec2(-s, s), c + vec2(s, -s)], stroke);
"Close"
},
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.on_hover_text(label)
}
/// A hairline round the window, and resizing from its edges and corners (when not maximized).
pub fn window_edges(ctx: &egui::Context) {
use egui::CursorIcon as C;
use egui::viewport::ResizeDirection as D;
let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false)));
if maximized || fullscreen || !custom_caption_buttons() {
return;
}
let r = ctx.content_rect();
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("window-border"))).rect_stroke(r, 0.0, theme::hairline(), egui::StrokeKind::Inside);
let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)) else {
return;
};
let near = |d: f32| d < RESIZE_EDGE;
// A corner takes a little of each edge next to it.
let corner = |d: f32| d < 3.0 * RESIZE_EDGE;
let (left, right, top, bottom) = (p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y);
let hit = if (near(top) && corner(left)) || (near(left) && corner(top)) {
Some((D::NorthWest, C::ResizeNorthWest))
} else if (near(top) && corner(right)) || (near(right) && corner(top)) {
Some((D::NorthEast, C::ResizeNorthEast))
} else if (near(bottom) && corner(left)) || (near(left) && corner(bottom)) {
Some((D::SouthWest, C::ResizeSouthWest))
} else if (near(bottom) && corner(right)) || (near(right) && corner(bottom)) {
Some((D::SouthEast, C::ResizeSouthEast))
} else if near(top) {
Some((D::North, C::ResizeNorth))
} else if near(bottom) {
Some((D::South, C::ResizeSouth))
} else if near(left) {
Some((D::West, C::ResizeWest))
} else if near(right) {
Some((D::East, C::ResizeEast))
} else {
None
};
if let Some((direction, cursor)) = hit {
ctx.set_cursor_icon(cursor);
if ctx.input(|i| i.pointer.primary_pressed()) {
ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction));
}
}
}

View File

@ -6,6 +6,7 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@ -55,6 +56,11 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,

View File

@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
@ -156,6 +157,12 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<CommonBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<CommonVideoOutputFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
@ -229,6 +236,8 @@ impl Default for OmniGenVideoModelDetails {
resolution_default: None,
bitrate_options: None,
bitrate_default: None,
output_format_options: None,
output_format_default: None,
quality_options: None,
default_quality: None,
duration_seconds_min: None,

View File

@ -0,0 +1,73 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeRequest {}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeRequest {
pub approval_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct DecideLoginChallengeRequest {
pub approval_token: String,
pub approve: bool,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct PollLoginChallengeRequest {
pub device_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeResponse {
pub success: bool,
pub device_token: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub confirmation_code: String,
pub requesting_ip: String,
pub expires_at: DateTime<Utc>,
pub username: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
/// Present only to the device after the redemption transaction commits.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_signed_session: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeState {
Pending,
Approved,
Redeemed,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeFailure {
UserDeclined,
Expired,
#[serde(other)]
Unknown,
}

View File

@ -2,4 +2,5 @@ pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod login;
pub mod session_info;
pub mod session_info;
pub mod login_challenges;

View File

@ -1,11 +1,12 @@
use crate::common::responses::user_details_light::UserDetailsLight;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use serde_derive::Serialize;
use serde_derive::{Deserialize, Serialize};
use serde::{Deserialize as DeserializeTrait, Deserializer};
use std::collections::BTreeSet;
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
#[derive(Serialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FakeYouPlan {
Free,
@ -14,7 +15,7 @@ pub enum FakeYouPlan {
Pro,
}
#[derive(Serialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerStreamPlan {
Free,
@ -23,7 +24,7 @@ pub enum StorytellerStreamPlan {
Pro,
}
#[derive(Serialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionUserInfo {
pub core_info: UserDetailsLight,
@ -41,6 +42,7 @@ pub struct SessionUserInfo {
/// Collection of feature / rollout flags
/// This is the proper place to detect if a user has access to some rollout (non-paywall) feature.
/// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes
#[serde(default, deserialize_with = "deserialize_known_feature_flags")]
pub maybe_feature_flags: BTreeSet<UserFeatureFlag>,
// Premium plans:
@ -76,7 +78,7 @@ pub struct SessionUserInfo {
pub can_delete_users: bool,
}
#[derive(Serialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
@ -91,9 +93,18 @@ pub struct SessionOnboardingState {
pub username_not_customized: bool,
}
#[derive(Serialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
// Server rollouts may introduce flags before a desktop release understands them.
// Ignore only unknown flag names; malformed values and authentication fields
// still fail decoding. Unknown flags never grant a client-side capability.
fn deserialize_known_feature_flags<'de, D>(deserializer: D) -> Result<BTreeSet<UserFeatureFlag>, D::Error>
where D: Deserializer<'de> {
let names = Vec::<String>::deserialize(deserializer)?;
Ok(names.into_iter().filter_map(|name| UserFeatureFlag::from_str(&name).ok()).collect())
}

View File

@ -0,0 +1,27 @@
# artcraft_client
Rust HTTP endpoint bindings used by the ArtCraft Tauri desktop app.
## Endpoint layout
- Each HTTP endpoint binding must live in its own descriptively named `.rs` file.
This applies to both free functions and methods implemented on a shared client.
Do not combine several routes into a catch-all file such as `login_challenges.rs`
or `password_auth.rs`.
- Group related bindings in a directory module. Keep `mod.rs` for module declarations;
it must not contain endpoint implementations. For example:
`endpoints/users/login_challenges/create_login_challenge.rs` and
`endpoints/users/login_challenges/poll_login_challenge.rs`.
- Keep each endpoint's route constant, request construction, and endpoint-specific
request/response types in its file. Keep shared wire types in `artcraft_api_defs`
or a dedicated shared type module; do not duplicate them across endpoint files.
- Put shared HTTP transport, client setup, and common error handling in a separate
utility module. A transport module must not accumulate endpoint bindings.
- Update callers to import types from their new modules when splitting bindings.
Preserve request methods, paths, headers, error behavior, and cookie handling.
## Validation
Run `cargo check --offline -p artcraft_client` for client changes. For native login
bindings, also run `SQLX_OFFLINE=true cargo test --offline -p artcraft --lib
login_bridge_tests`; these tests use local HTTP fixtures and temporary cookie jars.

View File

@ -19,6 +19,7 @@ path = "src/lib.rs"
# Internal
artcraft_api_defs.workspace = true
artcraft_client_identity.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
errors.workspace = true

View File

@ -29,7 +29,7 @@ pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToke
debug!("Requesting {:?}", &url);
let response = http_get_anonymous(url).await?;
let response = http_get_anonymous(api_host, url).await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;

View File

@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@ -56,8 +56,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
//let mut file = File::open(path)?;
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@ -86,7 +85,6 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {

View File

@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@ -11,7 +12,6 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
@ -72,8 +72,7 @@ pub async fn upload_image_media_file_from_bytes(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_bytes(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {

View File

@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@ -65,8 +65,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
//let mut file = File::open(path)?;
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {

View File

@ -1,7 +1,8 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@ -32,8 +32,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()?;
let file_bytes = std::fs::read(path.as_ref())?;
@ -49,7 +48,6 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {

View File

@ -1,7 +1,8 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@ -58,8 +58,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()?;
let file_bytes = std::fs::read(args.path.as_ref())?;
@ -78,7 +77,6 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {

View File

@ -7,3 +7,4 @@ pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;

View File

@ -158,6 +158,10 @@ pub struct OmniGenVideoModelDetails {
#[serde(default)]
pub bitrate_default: Option<ApiClientBitrate>,
#[serde(default)]
pub output_format_options: Option<Vec<String>>,
#[serde(default)]
pub output_format_default: Option<String>,
#[serde(default)]
pub quality_options: Option<Vec<ApiClientQuality>>,
#[serde(default)]
pub default_quality: Option<ApiClientQuality>,

View File

@ -0,0 +1,16 @@
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const CREATE_PATH: &str = "/v1/login_challenges/create";
impl LoginChallengeClient {
pub async fn create(&self) -> Result<CreateLoginChallengeResponse, LoginChallengeClientError> {
let request = self
.request(Method::POST, CREATE_PATH)
.json(&CreateLoginChallengeRequest {});
let (result, _) = self.send(request, CREATE_PATH).await?;
Ok(result)
}
}

View File

@ -0,0 +1,2 @@
pub mod create_login_challenge;
pub mod poll_login_challenge;

View File

@ -0,0 +1,30 @@
use artcraft_api_defs::users::login_challenges::{LoginChallengeResponse, PollLoginChallengeRequest};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const POLL_PATH: &str = "/v1/login_challenges/poll";
// Deliberately no Debug/Serialize: the wire result contains a bearer credential.
pub struct PollLoginChallengeResult {
pub response: LoginChallengeResponse,
pub session_set_cookie: Option<String>,
}
impl LoginChallengeClient {
pub async fn poll(
&self,
device_token: &str,
) -> Result<PollLoginChallengeResult, LoginChallengeClientError> {
let request = self
.request(Method::POST, POLL_PATH)
.json(&PollLoginChallengeRequest {
device_token: device_token.to_owned(),
});
let (response, session_set_cookie) = self.send(request, POLL_PATH).await?;
Ok(PollLoginChallengeResult {
response,
session_set_cookie,
})
}
}

View File

@ -0,0 +1,5 @@
pub mod login_challenges;
pub mod password_auth_response;
pub mod password_login;
pub mod password_signup;
pub mod session_info;

View File

@ -0,0 +1,8 @@
use serde::Deserialize;
// No Debug: the response contains a bearer credential.
#[derive(Deserialize)]
pub struct PasswordAuthResponse {
pub success: bool,
pub signed_session: Option<String>,
}

View File

@ -0,0 +1,20 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const LOGIN_PATH: &str = "/v1/login";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordLoginRequest {
pub username_or_email: String,
pub password: String,
}
impl LoginChallengeClient {
pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, LOGIN_PATH).json(request), LOGIN_PATH).await
}
}

View File

@ -0,0 +1,23 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const SIGNUP_PATH: &str = "/v1/create_account";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordSignupRequest {
pub username: String,
pub email_address: String,
pub password: String,
pub password_confirmation: String,
pub signup_source: String,
}
impl LoginChallengeClient {
pub async fn password_signup(&self, request: &PasswordSignupRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, SIGNUP_PATH).json(request), SIGNUP_PATH).await
}
}

View File

@ -0,0 +1,19 @@
use artcraft_api_defs::users::session_info::SessionInfoSuccessResponse;
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const SESSION_PATH: &str = "/v1/session";
impl LoginChallengeClient {
pub async fn session(
&self,
signed_session: &str,
) -> Result<SessionInfoSuccessResponse, LoginChallengeClientError> {
let request = self
.request(Method::GET, SESSION_PATH)
.header("Cookie", format!("session={signed_session}"));
let (result, _) = self.send(request, SESSION_PATH).await?;
Ok(result)
}
}

View File

@ -1,4 +1,6 @@
use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
const HTTP_SCHEME: &str = "http";
const HTTPS_SCHEME: &str = "https";
@ -27,6 +29,15 @@ impl ApiHost {
}
}
/// The `Origin` the desktop client declares to this API.
pub fn request_origin(&self) -> &'static str {
match self {
ApiHost::Storyteller => ARTCRAFT_DESKTOP_ORIGIN,
ApiHost::FakeYou => ARTCRAFT_DESKTOP_ORIGIN,
ApiHost::Localhost { .. } => ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN,
}
}
pub fn scheme(&self) -> &'static str {
match self {
ApiHost::Storyteller => HTTPS_SCHEME,

View File

@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@ -21,13 +21,11 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
info!("DELETE {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.delete(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);

View File

@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@ -20,13 +20,11 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {

View File

@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@ -30,13 +30,11 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
info!("Requesting {:?}", &url);
}
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);

View File

@ -3,11 +3,11 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use errors::AnyhowResult;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@ -26,13 +26,11 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {

View File

@ -1,4 +1 @@
pub const USER_AGENT: &str = "storyteller-client/1.0";
pub const APPLICATION_JSON : &str = "application/json";

View File

@ -1,14 +1,12 @@
use crate::error::api_error::ApiError;
use reqwest::Client;
const USER_AGENT: &str = "storyteller-client/1.0";
use crate::utils::api_host::ApiHost;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
let client = Client::builder()
.gzip(true)
pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result<reqwest::Response, ApiError> {
let client = storyteller_client_builder(api_host)
.build()?;
let response = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
//.header("Accept-Encoding", "gzip, deflate, br")
.send()

View File

@ -0,0 +1,184 @@
use std::collections::BTreeMap;
use std::time::Duration;
use log::{info, warn};
use reqwest::header::SET_COOKIE;
use reqwest::{Client, Method, RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use url::Url;
use crate::utils::api_host::ApiHost;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
/// Rejection bodies are small JSON objects; anything larger is not one of ours.
const MAX_REJECTION_BODY_BYTES: usize = 16 * 1024;
/// Native transport for the session bridge. No request/response bodies, cookies,
/// or challenge credentials are logged, even at debug level. Never follows redirects.
#[derive(Clone)]
pub struct LoginChallengeClient {
client: Client,
api_host: ApiHost,
}
#[derive(Debug)]
pub struct LoginChallengeClientError {
pub status: Option<u16>,
pub message: &'static str,
/// The server's explanation for a non-2xx response, when it sent one.
pub maybe_rejection: Option<LoginRejection>,
}
/// The error fields of a rejected login or signup response, such as
/// `{"error_type":"UsernameTaken","error_fields":{"username":"username is taken"}}`.
/// Only these fields are parsed, so no credential can end up in this struct.
#[derive(Debug, Default, Deserialize)]
pub struct LoginRejection {
pub error_type: Option<String>,
pub error_message: Option<String>,
#[serde(default)]
pub error_fields: BTreeMap<String, String>,
}
impl LoginChallengeClient {
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
let mut builder = storyteller_client_builder(api_host);
if matches!(api_host, ApiHost::Localhost { .. }) {
builder = builder.no_proxy();
}
let client = builder
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
Ok(Self {
client,
api_host: api_host.clone(),
})
}
pub fn api_origin(&self) -> String {
self.api_host.to_api_hostname_and_scheme()
}
pub fn api_url(&self) -> Url {
Url::parse(&format!("{}/", self.api_origin())).expect("ApiHost must produce a valid origin")
}
pub fn allows_verification_url(&self, value: &str) -> bool {
let Ok(url) = Url::parse(value) else {
return false;
};
let origin_allowed = match self.api_host {
ApiHost::Localhost { .. } => ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::Storyteller => ["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::FakeYou => false,
};
origin_allowed
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/login/desktop"
&& url.query().is_none()
&& url
.fragment()
.and_then(|f| f.strip_prefix("approval_token="))
.map(|s| {
s.len() == 43
&& s
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
.unwrap_or(false)
}
pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
self
.client
.request(method, format!("{}{path}", self.api_origin()))
.header("Accept", "application/json")
}
pub(crate) async fn send<T: DeserializeOwned>(
&self,
request: RequestBuilder,
path: &str,
) -> Result<(T, Option<String>), LoginChallengeClientError> {
let origin = self.api_origin();
info!("Website login request: origin={} path={}", origin, path);
let response = request.send().await.map_err(|_| {
warn!(
"Website login network failure: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Unable to reach the login server")
})?;
let status = response.status();
if !status.is_success() {
warn!(
"Website login HTTP failure: origin={} path={} status={}",
origin,
path,
status.as_u16()
);
return Err(LoginChallengeClientError {
status: Some(status.as_u16()),
message: "Login server rejected the request",
maybe_rejection: read_rejection(response).await,
});
}
let session_cookie = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find(|v| {
cookie::Cookie::parse(*v)
.map(|c| c.name() == "session")
.unwrap_or(false)
})
.map(str::to_owned);
let body = response.json().await.map_err(|_| {
warn!(
"Website login invalid response: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Invalid response from login server")
})?;
Ok((body, session_cookie))
}
}
impl LoginChallengeClientError {
pub fn invalid(message: &'static str) -> Self {
Self {
status: None,
message,
maybe_rejection: None,
}
}
}
impl std::fmt::Display for LoginChallengeClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status {
Some(status) => write!(f, "{} (HTTP {})", self.message, status),
None => write!(f, "{}", self.message),
}
}
}
impl std::error::Error for LoginChallengeClientError {}
async fn read_rejection(response: Response) -> Option<LoginRejection> {
if response.content_length().is_some_and(|len| len > MAX_REJECTION_BODY_BYTES as u64) {
return None;
}
let body = response.bytes().await.ok()?;
if body.len() > MAX_REJECTION_BODY_BYTES {
return None;
}
serde_json::from_slice(&body).ok()
}

View File

@ -4,6 +4,8 @@ pub mod basic_json_post_request;
pub (crate) mod basic_query_string_post_request;
pub (crate) mod filter_bad_response;
pub (crate) mod http_get_anonymous;
pub (crate) mod storyteller_client_builder;
pub mod api_host;
pub mod constants;
pub mod status_codes;
pub mod login_challenge_client;

View File

@ -0,0 +1,17 @@
use artcraft_client_identity::user_agents::ARTCRAFT_DESKTOP_USER_AGENT;
use reqwest::header::{HeaderMap, HeaderValue, ORIGIN};
use reqwest::{Client, ClientBuilder};
use crate::utils::api_host::ApiHost;
/// Every request to our API is built from this so the desktop client identifies itself
/// consistently (`User-Agent` and `Origin`). Only use it for Storyteller / ArtCraft APIs.
pub(crate) fn storyteller_client_builder(api_host: &ApiHost) -> ClientBuilder {
let mut headers = HeaderMap::new();
headers.insert(ORIGIN, HeaderValue::from_static(api_host.request_origin()));
Client::builder()
.user_agent(ARTCRAFT_DESKTOP_USER_AGENT)
.default_headers(headers)
.gzip(true)
}

View File

@ -1,10 +1,20 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
/// Not every model will use this; models that don't simply ignore it, and models that only
/// offer a subset of tiers clamp to the nearest tier they support.
///
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterQuality {
/// Let the model choose. Models without a native "auto" use their default tier.
Auto,
/// Highest tier (GPT Image 2.5 `max`).
Max,
/// Between High and Max (GPT Image 2.5 `xhigh`).
#[serde(rename = "xhigh")]
XHigh,
High,
Medium,
Low,

View File

@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
match q {
RouterQuality::Low => CommonQualityEnum::Low,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Auto => CommonQualityEnum::Auto,
RouterQuality::Max => CommonQualityEnum::Max,
RouterQuality::XHigh => CommonQualityEnum::XHigh,
RouterQuality::High => CommonQualityEnum::High,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Low => CommonQualityEnum::Low,
}
}

View File

@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let is_square = is_square_or_none(self.aspect_ratio);
let output_cost_per_image: u64 = match (quality, is_square) {
@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState {
(CommonQualityEnum::Medium, false) => 7,
(CommonQualityEnum::High, true) => 17,
(CommonQualityEnum::High, false) => 25,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let output_cost = output_cost_per_image * self.num_images as u64;
@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState {
}
}
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
use CommonAspectRatioEnum as Ar;
matches!(

View File

@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState {
(CommonQualityEnum::High, SizeBucket::Square) => 14,
(CommonQualityEnum::High, _) => 20,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState {
}
}
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {

View File

@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState {
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@ -77,6 +79,20 @@ impl ArtcraftGptImage2CostState {
}
}
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {

View File

@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
/// resolution tier the same way the Fal request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
/// landscape-first; portrait orientations of the same dimensions price identically.
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
/// request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
// 1:1
(1024, 1024, [ 65, 152, 582]),
(2048, 2048, [103, 239, 919]),
(2880, 2880, [151, 352, 1357]),
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
(2048, 2048, [103, 239, 919, 1633, 3672]),
(2880, 2880, [151, 352, 1357, 2412, 5426]),
// 4:3
(1024, 768, [ 46, 105, 400]),
(2048, 1536, [ 73, 170, 652]),
(3072, 2304, [120, 279, 1073]),
(3312, 2480, [134, 311, 1194]),
(1024, 768, [ 46, 105, 400, 710, 1597]),
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
(3072, 2304, [120, 279, 1073, 1906, 4287]),
(3312, 2480, [134, 311, 1194, 2122, 4773]),
// 16:9
(1088, 608, [ 32, 75, 285]),
(1920, 1080, [ 49, 114, 436]),
(2048, 1152, [ 53, 121, 467]),
(3072, 1728, [ 87, 204, 783]),
(3840, 2160, [124, 286, 1102]),
(1088, 608, [ 32, 75, 285, 506, 1139]),
(1920, 1080, [ 49, 114, 436, 775, 1743]),
(2048, 1152, [ 53, 121, 467, 830, 1865]),
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
(3840, 2160, [124, 286, 1102, 1957, 4404]),
];
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs {
}
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// high, and `auto` is priced as high (the documented default the model falls back to).
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// matching the Fal defaults the request would fall back to.
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
match quality {
CommonQualityEnum::Low => prices[0],
CommonQualityEnum::Medium => prices[1],
CommonQualityEnum::High => prices[2],
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
CommonQualityEnum::XHigh => prices[3],
CommonQualityEnum::Max => prices[4],
}
}
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
let long_edge = width.max(height);
let short_edge = width.min(height);
OUTPUT_IMAGE_PRICE_TABLE.iter()
@ -176,6 +181,18 @@ mod tests {
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
];
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
(None, None, [ 8, 16]), // landscape 4:3 default
(Some(Ar::Square), None, [11, 24]),
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
(Some(Ar::Square), Some(R::FourK), [25, 55]),
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
(Some(Ar::Auto), None, [20, 45]),
];
mod text_to_image_tests {
use super::*;
@ -189,11 +206,32 @@ mod tests {
}
}
#[test]
fn upper_tier_grid() {
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
}
}
}
#[test]
fn quality_defaults_to_high() {
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
}
#[test]
fn auto_prices_as_high() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
assert_eq!(
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
"{aspect_ratio:?} {resolution:?}",
);
}
}
#[test]
fn batch_multiplies_per_image_cents() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
@ -265,7 +303,7 @@ mod tests {
#[test]
fn prices_rise_with_quality_and_size() {
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
}
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));

View File

@ -100,11 +100,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@ -337,6 +343,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {

View File

@ -100,11 +100,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@ -337,6 +343,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {

View File

@ -106,11 +106,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@ -362,6 +368,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {

View File

@ -82,13 +82,16 @@ fn plan_num_images(
}
}
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
/// not reachable through it. Fal's default (`high`) applies when unspecified.
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
/// unspecified.
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
match quality {
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
}
}
@ -220,9 +223,12 @@ mod tests {
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Auto), "Some(Auto)"),
(Some(RouterQuality::Max), "Some(Max)"),
(Some(RouterQuality::XHigh), "Some(XHigh)"),
(Some(RouterQuality::High), "Some(High)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Low), "Some(Low)"),
];
for (quality, expected) in cases {
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));

View File

@ -53,6 +53,9 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),

View File

@ -53,6 +53,9 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),

View File

@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
}
}

View File

@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}

Some files were not shown because too many files have changed in this diff Show More