Compare commits
17 Commits
bt-new-mod
...
egui-nativ
| Author | SHA1 | Date |
|---|---|---|
|
|
244ba8cbcb | |
|
|
b5614ad333 | |
|
|
8abde75133 | |
|
|
910bfe4e84 | |
|
|
7aebfc1c59 | |
|
|
3e5793b693 | |
|
|
3210977fb1 | |
|
|
90c7a8a118 | |
|
|
1b27c31531 | |
|
|
93b664d871 | |
|
|
92e6f438c3 | |
|
|
df707bf8e1 | |
|
|
780e4494e9 | |
|
|
c3844acdad | |
|
|
8456e2fd06 | |
|
|
95f8f659a5 | |
|
|
3c5038c159 |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
86
LICENSE.md
86
LICENSE.md
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod grid;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod account;
|
||||
pub mod library_picker;
|
||||
pub mod lightbox;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" })
|
||||
}
|
||||
|
|
@ -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", ¤t, &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", ¤t, &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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
pub mod common;
|
||||
pub mod create_image;
|
||||
pub mod create_video;
|
||||
pub mod other;
|
||||
pub mod toolbar;
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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", ¤t.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", ¤t.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", ¤t.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(¤t.to_string()), "Number of generations", false));
|
||||
return None;
|
||||
}
|
||||
pickers::option_picker(ui, id, Trigger::Ghost(Icon::Copy), &format!("No. of {noun}"), "Number of generations", ¤t, &options)
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
pub mod creator_icons;
|
||||
pub mod icons;
|
||||
pub mod toast;
|
||||
pub mod widgets;
|
||||
pub mod window;
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ pub mod media_files;
|
|||
pub mod prompts;
|
||||
pub mod stripe_artcraft;
|
||||
pub mod subscriptions;
|
||||
pub mod users;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
pub mod create_login_challenge;
|
||||
pub mod poll_login_challenge;
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
|
||||
pub const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
|
||||
pub const APPLICATION_JSON : &str = "application/json";
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue