mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
artcraft-native: animated previews, playback and @Characters
- Feed: videos play their animated CDN previews (decoded off-thread, capped at 48 frames / 320 px), with a "Play video previews / Still thumbnails" toggle on Video and Library. - Lightbox: in-app video playback through FFmpeg when it is on the PATH (ffprobe with a small probe, RGBA frames into a texture, PCM into rodio; pause, seek, mute, loop), else the system player. - Audio reference cards play and stop on click (rodio + symphonia). - @Characters: mentions for models that take character references, the Characters dialog (create from a reference image, edit, delete), and reference_character_tokens resolved like the desktop app.
This commit is contained in:
Generated
+324
-7
@@ -184,6 +184,28 @@ version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -196,9 +218,9 @@ dependencies = [
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -480,6 +502,7 @@ dependencies = [
|
||||
"reqwest 0.12.23",
|
||||
"resvg",
|
||||
"rfd",
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokens",
|
||||
@@ -1730,6 +1753,49 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.12"
|
||||
@@ -1930,6 +1996,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.5.0"
|
||||
@@ -2696,6 +2768,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fal_client"
|
||||
version = "0.0.1"
|
||||
@@ -4832,6 +4910,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.35.0"
|
||||
@@ -5075,6 +5162,20 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.63",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -5084,7 +5185,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.63",
|
||||
@@ -5096,6 +5197,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -5642,6 +5752,29 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6917,6 +7050,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"symphonia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
@@ -8258,6 +8401,151 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-adpcm",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-adpcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -8366,8 +8654,8 @@ dependencies = [
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk-sys",
|
||||
"ndk 0.9.0",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -10447,6 +10735,16 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -10490,6 +10788,16 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
@@ -10603,6 +10911,15 @@ dependencies = [
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -10985,7 +11302,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -11296,7 +11613,7 @@ dependencies = [
|
||||
"javascriptcore-rs",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
|
||||
@@ -31,6 +31,7 @@ open = "5"
|
||||
reqwest = { workspace = true, features = ["multipart"] }
|
||||
resvg = "0.45"
|
||||
rfd = "0.16"
|
||||
rodio = { version = "0.20", default-features = false, features = ["symphonia-all"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time", "fs"] }
|
||||
|
||||
@@ -69,10 +69,11 @@ How the backend works:
|
||||
| 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 |
|
||||
| `@Character` mentions and the Characters modal (create/edit/delete) | Done |
|
||||
| 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 |
|
||||
| In-app video playback | Done with FFmpeg on the PATH; otherwise opens in the system player |
|
||||
| Audio reference playback (click to play/stop) | Done |
|
||||
| Animated video previews in the feed (with a stills toggle) | Done |
|
||||
|
||||
Feed and lightbox:
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
use egui::{Id, Ui};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::backend::audio::AudioPlayer;
|
||||
use crate::backend::media_cache::MediaCache;
|
||||
use crate::backend::wire::{Prompt, SessionUser};
|
||||
use crate::backend::{Backend, Event, Modality, SHARE_URL_BASE};
|
||||
@@ -14,7 +15,9 @@ use crate::feed::grid::{RatioCache, ViewMode};
|
||||
use crate::feed::store::{FeedNotice, FeedStore};
|
||||
use crate::feed::types::{FeedItem, MediaKind};
|
||||
use crate::models::Catalog;
|
||||
use crate::backend::wire::{Character, JobPhase};
|
||||
use crate::overlays::account::{LoginAction, LoginDialog, SettingsAction, settings_dialog};
|
||||
use crate::overlays::characters::{CharactersAction, CharactersModal};
|
||||
use crate::overlays::library_picker::{LibraryPicker, PickerAction};
|
||||
use crate::overlays::lightbox::{Lightbox, LightboxAction, fit_inside};
|
||||
use crate::pages::common::{self, AppRequest, Env, FeedKey};
|
||||
@@ -22,7 +25,7 @@ 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::shell::{self, Account, FeedToggles, Page, ShellAction};
|
||||
use crate::theme;
|
||||
use crate::ui::toast::Toasts;
|
||||
use crate::ui::{creator_icons, widgets, window};
|
||||
@@ -31,7 +34,7 @@ use crate::ui::{creator_icons, widgets, window};
|
||||
/// 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 }
|
||||
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, autoplay: $app.autoplay, ratios: &mut $app.ratios, prompts: &$app.prompts, requests: &mut $app.requests, characters: &$app.characters }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ struct Saved {
|
||||
page: Page,
|
||||
enter_to_generate: bool,
|
||||
view_mode: ViewMode,
|
||||
autoplay: bool,
|
||||
sidebar_open: bool,
|
||||
image: ImageSettings,
|
||||
video: VideoSettings,
|
||||
@@ -56,13 +60,14 @@ struct Saved {
|
||||
|
||||
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() }
|
||||
Self { page: Page::CreateImage, enter_to_generate: false, view_mode: ViewMode::Grid, autoplay: true, sidebar_open: true, image: ImageSettings { count: 1, ..Default::default() }, video: VideoSettings::default() }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArtcraftApp {
|
||||
backend: Backend,
|
||||
cache: MediaCache,
|
||||
audio: AudioPlayer,
|
||||
toasts: Toasts,
|
||||
catalog: Catalog,
|
||||
user: Option<SessionUser>,
|
||||
@@ -70,6 +75,7 @@ pub struct ArtcraftApp {
|
||||
page: Page,
|
||||
enter_to_generate: bool,
|
||||
view_mode: ViewMode,
|
||||
autoplay: bool,
|
||||
sidebar_open: bool,
|
||||
image: CreateImagePage,
|
||||
video: CreateVideoPage,
|
||||
@@ -86,6 +92,10 @@ pub struct ArtcraftApp {
|
||||
lightbox: Option<Lightbox>,
|
||||
picker: Option<LibraryPicker>,
|
||||
login: Option<LoginDialog>,
|
||||
characters_modal: Option<CharactersModal>,
|
||||
characters: Vec<Character>,
|
||||
/// Characters being made: (job token, name).
|
||||
pending_characters: Vec<(String, String)>,
|
||||
settings_open: bool,
|
||||
preview: Option<String>,
|
||||
requests: Vec<AppRequest>,
|
||||
@@ -105,10 +115,11 @@ impl ArtcraftApp {
|
||||
}
|
||||
let backend = Backend::new(cc.egui_ctx.clone());
|
||||
let cache = MediaCache::new(backend.runtime(), backend.http());
|
||||
let audio = AudioPlayer::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 }
|
||||
Self { cache, audio, 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, autoplay: saved.autoplay, 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, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), 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 -------------------------------------------------------------
|
||||
@@ -126,6 +137,9 @@ impl ArtcraftApp {
|
||||
}
|
||||
if self.user.is_some() {
|
||||
self.login = None;
|
||||
if changed {
|
||||
self.backend.load_characters();
|
||||
}
|
||||
} else {
|
||||
self.credits = None;
|
||||
if let Some(login) = &mut self.login {
|
||||
@@ -176,6 +190,16 @@ impl ArtcraftApp {
|
||||
}
|
||||
},
|
||||
Event::Uploaded { ref_id, result } => {
|
||||
if let Some(modal) = &mut self.characters_modal {
|
||||
match modal.apply_upload(ref_id, result.clone()) {
|
||||
Some(Err(err)) => {
|
||||
self.toasts.error(format!("Failed to upload the reference image: {err}"));
|
||||
return;
|
||||
},
|
||||
Some(Ok(())) => return,
|
||||
None => {},
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -184,6 +208,20 @@ impl ArtcraftApp {
|
||||
}
|
||||
},
|
||||
Event::Jobs(jobs) => {
|
||||
// Characters being made finish as jobs too.
|
||||
let before = self.pending_characters.len();
|
||||
let toasts = &mut self.toasts;
|
||||
self.pending_characters.retain(|(job, name)| match jobs.iter().find(|j| &j.job_token == job).map(|j| j.status.phase()) {
|
||||
Some(JobPhase::Succeeded) => false,
|
||||
Some(JobPhase::Failed) => {
|
||||
toasts.error(format!("Character \u{201c}{name}\u{201d} failed to create"));
|
||||
false
|
||||
},
|
||||
_ => true,
|
||||
});
|
||||
if self.pending_characters.len() != before {
|
||||
self.backend.load_characters();
|
||||
}
|
||||
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 {
|
||||
@@ -239,6 +277,13 @@ impl ArtcraftApp {
|
||||
}
|
||||
self.prompt_records.insert(prompt.token.clone(), prompt);
|
||||
},
|
||||
Event::Characters(list) => self.characters = list,
|
||||
Event::CharacterCreating { job_token, name } => {
|
||||
self.toasts.success(format!("Character \u{201c}{name}\u{201d} is being created"));
|
||||
self.pending_characters.push((job_token, name));
|
||||
self.next_poll = 0.0;
|
||||
},
|
||||
Event::CharacterChanged => self.backend.load_characters(),
|
||||
Event::Deleted(token) => {
|
||||
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
|
||||
feed.remove_item(&token);
|
||||
@@ -314,6 +359,20 @@ impl ArtcraftApp {
|
||||
},
|
||||
AppRequest::Download(tokens) => self.download(&tokens),
|
||||
AppRequest::Preview(url) => self.preview = Some(url),
|
||||
AppRequest::ToggleAudio { ref_id, url } => self.audio.toggle(ctx, &format!("ref:{ref_id}"), &url),
|
||||
AppRequest::OpenCharacters => {
|
||||
if self.user.is_none() {
|
||||
self.open_login();
|
||||
return;
|
||||
}
|
||||
self.backend.load_characters();
|
||||
self.characters_modal = Some(CharactersModal::new());
|
||||
},
|
||||
AppRequest::PlayExternally(url) => {
|
||||
if let Err(err) = open::that(&url) {
|
||||
self.toasts.error(format!("Couldn't open the file: {err}"));
|
||||
}
|
||||
},
|
||||
AppRequest::LoadMoreLibrary(key) => {
|
||||
let Some(user) = &self.user else {
|
||||
return;
|
||||
@@ -453,6 +512,7 @@ impl ArtcraftApp {
|
||||
},
|
||||
ShellAction::OpenSettings => self.settings_open = true,
|
||||
ShellAction::SetViewMode(mode) => self.view_mode = mode,
|
||||
ShellAction::SetAutoplay(on) => self.autoplay = on,
|
||||
ShellAction::ToggleSelect => {
|
||||
if let Some(feed) = self.current_feed() {
|
||||
feed.selecting = !feed.selecting;
|
||||
@@ -469,9 +529,9 @@ impl ArtcraftApp {
|
||||
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)),
|
||||
Page::CreateImage => Some(FeedToggles { mode: self.view_mode, selecting: self.image.feed.selecting, autoplay: None }),
|
||||
Page::CreateVideo => Some(FeedToggles { mode: self.view_mode, selecting: self.video.feed.selecting, autoplay: Some(self.autoplay) }),
|
||||
Page::Library => Some(FeedToggles { mode: self.view_mode, selecting: self.library.selecting, autoplay: Some(self.autoplay) }),
|
||||
_ => None,
|
||||
};
|
||||
let mut header_action = None;
|
||||
@@ -584,6 +644,27 @@ impl ArtcraftApp {
|
||||
None => {},
|
||||
}
|
||||
}
|
||||
if let Some(modal) = &mut self.characters_modal {
|
||||
let pending: Vec<String> = self.pending_characters.iter().map(|(_, n)| n.clone()).collect();
|
||||
for action in modal.show(ctx, &self.characters, &pending, &mut self.cache) {
|
||||
match action {
|
||||
CharactersAction::Close => self.characters_modal = None,
|
||||
CharactersAction::Select { token, name } => {
|
||||
self.video.mention_character(&name, &token);
|
||||
self.characters_modal = None;
|
||||
},
|
||||
CharactersAction::Upload { ref_id, path } => self.backend.upload_file(ref_id, RefKind::Image, path),
|
||||
CharactersAction::Create { image_token, name, description } => self.backend.create_character(image_token, name, description),
|
||||
CharactersAction::Edit { token, name, description } => self.backend.edit_character(token, name, description),
|
||||
CharactersAction::Delete { token, name } => self.backend.delete_character(token, name),
|
||||
CharactersAction::Preview(url) => self.preview = Some(url),
|
||||
CharactersAction::Toast(message) => self.toasts.error(message),
|
||||
}
|
||||
if self.characters_modal.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
@@ -663,6 +744,11 @@ impl eframe::App for ArtcraftApp {
|
||||
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.cache.poll(&ctx);
|
||||
if let Some(err) = self.audio.poll() {
|
||||
self.toasts.error(err);
|
||||
}
|
||||
let playing = [&self.video.refs, &self.image.refs].iter().flat_map(|r| r.audios.iter()).map(|r| r.id).find(|id| self.audio.is_playing(&format!("ref:{id}")));
|
||||
crate::prompt_box::deck::set_playing_audio(&ctx, playing);
|
||||
self.shell(ui);
|
||||
self.page_ui(ui);
|
||||
for request in std::mem::take(&mut self.requests) {
|
||||
@@ -677,7 +763,7 @@ impl eframe::App for ArtcraftApp {
|
||||
}
|
||||
|
||||
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() };
|
||||
let saved = Saved { page: self.page, enter_to_generate: self.enter_to_generate, view_mode: self.view_mode, autoplay: self.autoplay, sidebar_open: self.sidebar_open, image: self.image.settings.clone(), video: self.video.settings.clone() };
|
||||
eframe::set_value(storage, STORAGE_KEY, &saved);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Playing one audio clip at a time (reference cards play and stop on click, like the webapp's
|
||||
//! `DeckCard`). The output device opens on first use; files download in the background.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use log::warn;
|
||||
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
|
||||
|
||||
/// The webapp's preview volume.
|
||||
const PREVIEW_VOLUME: f32 = 0.2;
|
||||
|
||||
pub struct AudioPlayer {
|
||||
rt: tokio::runtime::Handle,
|
||||
http: reqwest::Client,
|
||||
/// Opened on first play (and kept: closing it would stop the sound).
|
||||
output: Option<(OutputStream, OutputStreamHandle)>,
|
||||
sink: Option<Sink>,
|
||||
/// What's playing (or loading), by the caller's key.
|
||||
current: Option<String>,
|
||||
tx: Sender<(String, Result<Vec<u8>, String>)>,
|
||||
rx: Receiver<(String, Result<Vec<u8>, String>)>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn new(rt: tokio::runtime::Handle, http: reqwest::Client) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
Self { rt, http, output: None, sink: None, current: None, tx, rx }
|
||||
}
|
||||
|
||||
/// Starts `url` under `key`, or stops it if it's what's playing.
|
||||
pub fn toggle(&mut self, ctx: &egui::Context, key: &str, url: &str) {
|
||||
if self.is_playing(key) {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
self.stop();
|
||||
self.current = Some(key.to_owned());
|
||||
let (http, tx, ctx, key, url) = (self.http.clone(), self.tx.clone(), ctx.clone(), key.to_owned(), url.to_owned());
|
||||
self.rt.spawn(async move {
|
||||
let bytes = async {
|
||||
let response = http.get(&url).send().await.and_then(|r| r.error_for_status()).map_err(|e| e.to_string())?;
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
|
||||
}
|
||||
.await;
|
||||
let _ = tx.send((key, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(sink) = self.sink.take() {
|
||||
sink.stop();
|
||||
}
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
/// Whether `key` is playing or about to.
|
||||
pub fn is_playing(&self, key: &str) -> bool {
|
||||
self.current.as_deref() == Some(key)
|
||||
}
|
||||
|
||||
/// Starts downloads that finished; notices clips that ended. Call once per frame. Returns an
|
||||
/// error message when a clip couldn't play.
|
||||
pub fn poll(&mut self) -> Option<String> {
|
||||
let mut error = None;
|
||||
while let Ok((key, bytes)) = self.rx.try_recv() {
|
||||
// Superseded by another clip (or stopped) while downloading.
|
||||
if self.current.as_deref() != Some(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
match bytes.and_then(|b| self.play_bytes(b)) {
|
||||
Ok(()) => {},
|
||||
Err(err) => {
|
||||
warn!("Audio preview failed: {err}");
|
||||
self.current = None;
|
||||
error = Some(format!("Couldn't play the audio: {err}"));
|
||||
},
|
||||
}
|
||||
}
|
||||
if self.sink.as_ref().is_some_and(Sink::empty) {
|
||||
self.sink = None;
|
||||
self.current = None;
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
fn play_bytes(&mut self, bytes: Vec<u8>) -> Result<(), String> {
|
||||
if self.output.is_none() {
|
||||
self.output = Some(OutputStream::try_default().map_err(|e| e.to_string())?);
|
||||
}
|
||||
let Some((_, handle)) = &self.output else {
|
||||
return Err("no audio output".to_owned());
|
||||
};
|
||||
let sink = Sink::try_new(handle).map_err(|e| e.to_string())?;
|
||||
let source = Decoder::new(Cursor::new(bytes)).map_err(|e| e.to_string())?;
|
||||
sink.set_volume(PREVIEW_VOLUME);
|
||||
sink.append(source);
|
||||
self.sink = Some(sink);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Images shown in the UI: CDN thumbnails and local previews, as GPU textures.
|
||||
//! Images shown in the UI: CDN thumbnails, animated video previews 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::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
use image::AnimationDecoder;
|
||||
use log::warn;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::Semaphore;
|
||||
@@ -17,10 +21,14 @@ use tokio::sync::Semaphore;
|
||||
const MAX_CONCURRENT_DOWNLOADS: usize = 6;
|
||||
/// Longest side of a decoded image; larger ones are downscaled before upload.
|
||||
const MAX_DECODED_SIDE: u32 = 2048;
|
||||
/// Animated previews: longest side and frame budget (each frame is its own texture).
|
||||
const ANIMATED_SIDE: u32 = 320;
|
||||
const ANIMATED_MAX_FRAMES: usize = 48;
|
||||
/// 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.
|
||||
/// Frames an image may go undrawn before it can be evicted (animations go sooner: they're heavy).
|
||||
const EVICT_AFTER_FRAMES: u64 = 600;
|
||||
const EVICT_ANIMATED_AFTER_FRAMES: u64 = 120;
|
||||
|
||||
pub struct MediaCache {
|
||||
rt: Handle,
|
||||
@@ -40,14 +48,30 @@ pub enum Lookup {
|
||||
}
|
||||
|
||||
enum Entry {
|
||||
Loading { last_used: u64 },
|
||||
Ready { texture: TextureHandle, last_used: u64 },
|
||||
Loading {
|
||||
last_used: u64,
|
||||
},
|
||||
Ready {
|
||||
texture: TextureHandle,
|
||||
last_used: u64,
|
||||
},
|
||||
/// Frames with how long each shows (seconds), and their total.
|
||||
Animated {
|
||||
frames: Vec<(TextureHandle, f32)>,
|
||||
total: f32,
|
||||
last_used: u64,
|
||||
},
|
||||
Failed,
|
||||
}
|
||||
|
||||
struct Loaded {
|
||||
key: String,
|
||||
image: Option<ColorImage>,
|
||||
decoded: Option<Decoded>,
|
||||
}
|
||||
|
||||
enum Decoded {
|
||||
Still(ColorImage),
|
||||
Frames(Vec<(ColorImage, f32)>),
|
||||
}
|
||||
|
||||
impl MediaCache {
|
||||
@@ -58,23 +82,13 @@ impl MediaCache {
|
||||
|
||||
/// 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
|
||||
},
|
||||
}
|
||||
self.lookup(ctx, url, url, false)
|
||||
}
|
||||
|
||||
/// The current frame of an animated image (a video's animated preview); still images come
|
||||
/// back as they are.
|
||||
pub fn get_animated(&mut self, ctx: &egui::Context, url: &str) -> Lookup {
|
||||
self.lookup(ctx, &format!("anim:{url}"), url, true)
|
||||
}
|
||||
|
||||
/// Decodes `bytes` off the UI thread and registers the result under `key`.
|
||||
@@ -85,8 +99,8 @@ impl MediaCache {
|
||||
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 });
|
||||
let decoded = decode(&bytes).map(Decoded::Still);
|
||||
let _ = tx.send(Loaded { key, decoded });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
@@ -94,39 +108,78 @@ impl MediaCache {
|
||||
/// 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() {
|
||||
while let Ok(Loaded { key, decoded }) = 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 },
|
||||
*entry = match decoded {
|
||||
Some(Decoded::Still(image)) => Entry::Ready { texture: ctx.load_texture(&key, image, TextureOptions::LINEAR), last_used: self.frame },
|
||||
Some(Decoded::Frames(frames)) => {
|
||||
let total = frames.iter().map(|(_, d)| d).sum();
|
||||
let frames = frames.into_iter().enumerate().map(|(i, (img, d))| (ctx.load_texture(format!("{key}#{i}"), img, TextureOptions::LINEAR), d)).collect();
|
||||
Entry::Animated { frames, total, 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,
|
||||
});
|
||||
let frame = self.frame;
|
||||
let crowded = self.entries.len() > SOFT_CAPACITY;
|
||||
self.entries.retain(|_, e| match e {
|
||||
Entry::Animated { last_used, .. } => frame.saturating_sub(*last_used) < EVICT_ANIMATED_AFTER_FRAMES,
|
||||
Entry::Ready { last_used, .. } | Entry::Loading { last_used } => !crowded || frame.saturating_sub(*last_used) < EVICT_AFTER_FRAMES,
|
||||
Entry::Failed => true,
|
||||
});
|
||||
}
|
||||
|
||||
fn lookup(&mut self, ctx: &egui::Context, key: &str, url: &str, animated: bool) -> Lookup {
|
||||
let frame = self.frame;
|
||||
match self.entries.get_mut(key) {
|
||||
Some(Entry::Ready { texture, last_used }) => {
|
||||
*last_used = frame;
|
||||
Lookup::Ready(texture.clone())
|
||||
},
|
||||
Some(Entry::Animated { frames, total, last_used }) => {
|
||||
*last_used = frame;
|
||||
let t = (ctx.input(|i| i.time) as f32).rem_euclid(total.max(0.01));
|
||||
let (mut acc, mut index) = (0.0, frames.len() - 1);
|
||||
for (i, (_, d)) in frames.iter().enumerate() {
|
||||
acc += d;
|
||||
if t < acc {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(Duration::from_secs_f32((acc - t).max(0.01)));
|
||||
Lookup::Ready(frames[index].0.clone())
|
||||
},
|
||||
Some(Entry::Loading { last_used }) => {
|
||||
*last_used = frame;
|
||||
Lookup::Loading
|
||||
},
|
||||
Some(Entry::Failed) => Lookup::Failed,
|
||||
None => {
|
||||
self.entries.insert(key.to_owned(), Entry::Loading { last_used: frame });
|
||||
self.spawn_download(ctx, key.to_owned(), url.to_owned(), animated);
|
||||
Lookup::Loading
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_download(&self, ctx: &egui::Context, url: String) {
|
||||
fn spawn_download(&self, ctx: &egui::Context, key: String, url: String, animated: bool) {
|
||||
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,
|
||||
let decoded = match limiter.acquire_owned().await {
|
||||
Ok(_permit) => download(&http, &url, animated).await,
|
||||
Err(_) => None,
|
||||
};
|
||||
let _ = tx.send(Loaded { key: url, image });
|
||||
let _ = tx.send(Loaded { key, decoded });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn download(http: &reqwest::Client, url: &str) -> Option<ColorImage> {
|
||||
async fn download(http: &reqwest::Client, url: &str, animated: bool) -> Option<Decoded> {
|
||||
let response = match http.get(url).send().await.and_then(|r| r.error_for_status()) {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
@@ -135,7 +188,7 @@ async fn download(http: &reqwest::Client, url: &str) -> Option<ColorImage> {
|
||||
},
|
||||
};
|
||||
let bytes = response.bytes().await.ok()?;
|
||||
tokio::task::spawn_blocking(move || decode(&bytes)).await.ok().flatten()
|
||||
tokio::task::spawn_blocking(move || if animated { decode_frames(&bytes).map(Decoded::Frames).or_else(|| decode(&bytes).map(Decoded::Still)) } else { decode(&bytes).map(Decoded::Still) }).await.ok().flatten()
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<ColorImage> {
|
||||
@@ -147,7 +200,45 @@ fn decode(bytes: &[u8]) -> Option<ColorImage> {
|
||||
},
|
||||
};
|
||||
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();
|
||||
Some(to_color_image(image.to_rgba8()))
|
||||
}
|
||||
|
||||
/// The frames of an animated GIF or WebP (subsampled to the budget), or `None` when it isn't
|
||||
/// animated.
|
||||
fn decode_frames(bytes: &[u8]) -> Option<Vec<(ColorImage, f32)>> {
|
||||
let frames = match image::guess_format(bytes).ok()? {
|
||||
image::ImageFormat::Gif => image::codecs::gif::GifDecoder::new(Cursor::new(bytes)).ok()?.into_frames().collect_frames().ok()?,
|
||||
image::ImageFormat::WebP => {
|
||||
let decoder = image::codecs::webp::WebPDecoder::new(Cursor::new(bytes)).ok()?;
|
||||
if !decoder.has_animation() {
|
||||
return None;
|
||||
}
|
||||
decoder.into_frames().collect_frames().ok()?
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
if frames.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Keep every n-th frame, folding the skipped frames' time into the kept one.
|
||||
let step = frames.len().div_ceil(ANIMATED_MAX_FRAMES);
|
||||
let mut out = Vec::with_capacity(frames.len() / step + 1);
|
||||
for chunk in frames.chunks(step) {
|
||||
let secs: f32 = chunk
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let (n, d) = f.delay().numer_denom_ms();
|
||||
n as f32 / d.max(1) as f32 / 1000.0
|
||||
})
|
||||
.sum();
|
||||
let image = image::DynamicImage::ImageRgba8(chunk[0].buffer().clone()).thumbnail(ANIMATED_SIDE, ANIMATED_SIDE);
|
||||
// Some encoders write 0 ms delays; browsers treat those as ~100 ms.
|
||||
out.push((to_color_image(image.to_rgba8()), if secs < 0.02 { 0.1 * chunk.len() as f32 } else { secs }));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn to_color_image(rgba: image::RgbaImage) -> ColorImage {
|
||||
let size = [rgba.width() as usize, rgba.height() as usize];
|
||||
Some(ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()))
|
||||
ColorImage::from_rgba_unmultiplied(size, rgba.as_raw())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 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 audio;
|
||||
pub mod media_cache;
|
||||
pub mod session;
|
||||
pub mod video;
|
||||
pub mod wire;
|
||||
|
||||
use std::future::Future;
|
||||
@@ -36,7 +38,7 @@ 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};
|
||||
use wire::{BatchMedia, CharactersPage, MediaFile, PromptResponse, SessionInfo, SessionJobs, SessionUser, UploadResponse, UserMediaList};
|
||||
|
||||
/// Items per library page (`useGalleryData`'s `PAGE_SIZE`).
|
||||
pub const LIBRARY_PAGE_SIZE: u32 = 40;
|
||||
@@ -45,6 +47,8 @@ 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/";
|
||||
/// Characters are made for this model (the Tauri app's `CharactersModal`).
|
||||
const CHARACTER_MODEL: &str = "seedance_2p0";
|
||||
/// Sent on every request (`artcraft_client_identity::ARTCRAFT_DESKTOP_USER_AGENT`).
|
||||
const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
|
||||
@@ -119,6 +123,14 @@ pub enum Event {
|
||||
items: Vec<MediaFile>,
|
||||
},
|
||||
Prompt(wire::Prompt),
|
||||
/// The user's characters, newest first.
|
||||
Characters(Vec<wire::Character>),
|
||||
/// A character is being made by job `job_token`.
|
||||
CharacterCreating {
|
||||
job_token: String,
|
||||
name: String,
|
||||
},
|
||||
CharacterChanged,
|
||||
Deleted(String),
|
||||
Saved(PathBuf),
|
||||
Toast {
|
||||
@@ -485,6 +497,92 @@ impl Backend {
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads every page of the user's characters.
|
||||
pub fn load_characters(&self) {
|
||||
let api = self.api();
|
||||
if api.creds.is_none() {
|
||||
return;
|
||||
}
|
||||
self.spawn(move |tx| async move {
|
||||
let mut all = Vec::new();
|
||||
let mut cursor: Option<i64> = None;
|
||||
loop {
|
||||
let path = match cursor {
|
||||
Some(c) => format!("/v1/characters/session?cursor={c}"),
|
||||
None => "/v1/characters/session".to_owned(),
|
||||
};
|
||||
match api.request_json::<CharactersPage>(reqwest::Method::GET, &path, None).await {
|
||||
Ok(page) => {
|
||||
all.extend(page.characters);
|
||||
match page.next_cursor {
|
||||
Some(next) if Some(next) != cursor => cursor = Some(next),
|
||||
_ => break,
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Loading characters failed: {err}");
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
let _ = tx.send(Event::Characters(all));
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts making a character from an uploaded reference image (`POST /v1/character/create`).
|
||||
pub fn create_character(&self, image_token: String, name: String, description: Option<String>) {
|
||||
let api = self.api();
|
||||
self.spawn(move |tx| async move {
|
||||
let body = serde_json::json!({ "image_media_token": image_token, "model": CHARACTER_MODEL, "uuid_idempotency_token": uuid::Uuid::new_v4().to_string(), "character_name": name, "character_description": description });
|
||||
match api.request_json::<Value>(reqwest::Method::POST, "/v1/character/create", Some(body)).await {
|
||||
Ok(r) => match r.get("inference_job_token").and_then(Value::as_str) {
|
||||
Some(job) => {
|
||||
let _ = tx.send(Event::CharacterCreating { job_token: job.to_owned(), name });
|
||||
},
|
||||
None => {
|
||||
let _ = tx.send(Event::Toast { error: true, message: "Failed to create character".to_owned() });
|
||||
},
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = tx.send(Event::Toast { error: true, message: if err.is_empty() { "Failed to create character".to_owned() } else { err } });
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Renames or re-describes a character (`POST /v1/character/edit`).
|
||||
pub fn edit_character(&self, token: String, name: String, description: String) {
|
||||
let api = self.api();
|
||||
self.spawn(move |tx| async move {
|
||||
let body = serde_json::json!({ "token": token, "updated_name": name, "updated_description": description });
|
||||
match api.request_json::<Value>(reqwest::Method::POST, "/v1/character/edit", Some(body)).await {
|
||||
Ok(_) => {
|
||||
let _ = tx.send(Event::Toast { error: false, message: "Character updated".to_owned() });
|
||||
let _ = tx.send(Event::CharacterChanged);
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = tx.send(Event::Toast { error: true, message: format!("Failed to update character: {err}") });
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `DELETE /v1/character/{token}`.
|
||||
pub fn delete_character(&self, token: String, name: String) {
|
||||
let api = self.api();
|
||||
self.spawn(move |tx| async move {
|
||||
match api.request_json::<Value>(reqwest::Method::DELETE, &format!("/v1/character/{token}"), None).await {
|
||||
Ok(_) => {
|
||||
let _ = tx.send(Event::Toast { error: false, message: format!("Character \"{name}\" deleted") });
|
||||
let _ = tx.send(Event::CharacterChanged);
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = tx.send(Event::Toast { error: true, message: format!("Failed to delete character: {err}") });
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn delete_media(&self, token: String) {
|
||||
let api = self.api();
|
||||
self.spawn(move |tx| async move {
|
||||
@@ -589,8 +687,16 @@ struct Api {
|
||||
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> {
|
||||
self.request_json(reqwest::Method::GET, path, None).await
|
||||
}
|
||||
|
||||
/// Any JSON call with the desktop app's identity headers and cookies, decoded leniently.
|
||||
async fn request_json<T: DeserializeOwned>(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> 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());
|
||||
let mut request = self.http.request(method, &url).header("Accept", "application/json").header("Origin", self.host.request_origin());
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) {
|
||||
request = request.header("Cookie", cookie);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! In-app video playback through FFmpeg (when it's installed): `ffprobe` reads the clip's size,
|
||||
//! rate and length, one `ffmpeg` streams RGBA frames into a texture and another streams PCM into
|
||||
//! rodio. Pausing and seeking restart both at the new position. Without FFmpeg the lightbox opens
|
||||
//! videos in the system player instead.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, channel, sync_channel};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::{Align2, Color32, ColorImage, FontId, Rect, Sense, TextureHandle, TextureOptions, Ui, pos2, vec2};
|
||||
use log::warn;
|
||||
use rodio::{OutputStream, OutputStreamHandle, Sink, Source};
|
||||
|
||||
use crate::theme;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
|
||||
/// Frames are scaled down to at most this width (the lightbox never shows them larger).
|
||||
const MAX_WIDTH: u32 = 1280;
|
||||
const AUDIO_RATE: u32 = 44_100;
|
||||
/// Decoded frames buffered ahead of the clock (back-pressure on `ffmpeg`).
|
||||
const FRAME_QUEUE: usize = 4;
|
||||
const CONTROLS_HEIGHT: f32 = 40.0;
|
||||
|
||||
/// Whether `ffmpeg` and `ffprobe` are on the PATH (checked once).
|
||||
pub fn ffmpeg_available() -> bool {
|
||||
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||
*AVAILABLE.get_or_init(|| ["ffmpeg", "ffprobe"].iter().all(|tool| command(tool).arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct VideoInfo {
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: f64,
|
||||
duration: f64,
|
||||
}
|
||||
|
||||
pub struct VideoPlayer {
|
||||
url: String,
|
||||
probe: Option<Receiver<Result<VideoInfo, String>>>,
|
||||
info: Option<VideoInfo>,
|
||||
stream: Option<Stream>,
|
||||
texture: Option<TextureHandle>,
|
||||
/// Media time of the frame on screen.
|
||||
position: f64,
|
||||
muted: bool,
|
||||
error: Option<String>,
|
||||
output: Option<(OutputStream, OutputStreamHandle)>,
|
||||
}
|
||||
|
||||
/// One run of the decoders from a start position.
|
||||
struct Stream {
|
||||
start: f64,
|
||||
started: Instant,
|
||||
frames: Receiver<(ColorImage, f64)>,
|
||||
next: Option<(ColorImage, f64)>,
|
||||
ended: bool,
|
||||
children: Vec<Child>,
|
||||
sink: Option<Sink>,
|
||||
}
|
||||
|
||||
impl Drop for Stream {
|
||||
fn drop(&mut self) {
|
||||
for child in &mut self.children {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoPlayer {
|
||||
/// Starts probing `url`; playback begins as soon as its size is known.
|
||||
pub fn new(url: &str) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let probe_url = url.to_owned();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(probe(&probe_url));
|
||||
});
|
||||
Self { url: url.to_owned(), probe: Some(rx), info: None, stream: None, texture: None, position: 0.0, muted: false, error: None, output: None }
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Draws the video fitted inside `rect` with its controls; `poster` (the still frame) shows
|
||||
/// until the first decoded frame arrives.
|
||||
pub fn ui(&mut self, ui: &mut Ui, rect: Rect, poster: Option<&TextureHandle>) {
|
||||
self.poll_probe();
|
||||
let ctx = ui.ctx().clone();
|
||||
self.advance(&ctx);
|
||||
ui.painter().rect_filled(rect, 0.0, Color32::BLACK);
|
||||
let video_area = Rect::from_min_max(rect.min, pos2(rect.right(), rect.bottom() - CONTROLS_HEIGHT));
|
||||
match (&self.texture, &self.error) {
|
||||
(_, Some(err)) => {
|
||||
ui.painter().text(video_area.center(), Align2::CENTER_CENTER, format!("Couldn't play this video: {err}"), FontId::new(13.0, egui::FontFamily::Proportional), theme::BAD);
|
||||
},
|
||||
(Some(texture), None) => {
|
||||
let fit = crate::overlays::lightbox::fit_inside(texture.size_vec2(), video_area.shrink(4.0).size());
|
||||
egui::Image::new(texture).paint_at(ui, Rect::from_center_size(video_area.center(), fit));
|
||||
},
|
||||
(None, None) => {
|
||||
if let Some(poster) = poster {
|
||||
let fit = crate::overlays::lightbox::fit_inside(poster.size_vec2(), video_area.shrink(4.0).size());
|
||||
egui::Image::new(poster).tint(Color32::from_gray(150)).paint_at(ui, Rect::from_center_size(video_area.center(), fit));
|
||||
}
|
||||
icons::paint_spinner(ui, Rect::from_center_size(video_area.center(), vec2(32.0, 32.0)), Color32::WHITE);
|
||||
},
|
||||
}
|
||||
// Clicking the picture toggles play/pause.
|
||||
let click = ui.interact(video_area, ui.id().with(("video-surface", &self.url)), Sense::click());
|
||||
if click.clicked() {
|
||||
self.toggle_pause();
|
||||
}
|
||||
self.controls(ui, Rect::from_min_max(pos2(rect.left(), rect.bottom() - CONTROLS_HEIGHT), rect.max));
|
||||
if self.stream.is_some() {
|
||||
ctx.request_repaint_after(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn controls(&mut self, ui: &mut Ui, bar: Rect) {
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(bar, 0.0, theme::fade(theme::PANEL, 0.95));
|
||||
let playing = self.stream.is_some();
|
||||
let button = Rect::from_center_size(pos2(bar.left() + 22.0, bar.center().y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(button, ui.id().with(("video-play", &self.url)), Sense::click()).on_hover_text(if playing { "Pause" } else { "Play" }).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if playing {
|
||||
for dx in [-3.5, 3.5] {
|
||||
p.rect_filled(Rect::from_center_size(button.center() + vec2(dx, 0.0), vec2(3.0, 12.0)), 0.0, theme::INK);
|
||||
}
|
||||
} else {
|
||||
icons::paint(&p, button.shrink(7.0), Icon::Play, theme::INK);
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.toggle_pause();
|
||||
}
|
||||
let duration = self.info.map_or(0.0, |i| i.duration);
|
||||
let time = format!("{} / {}", clock(self.position), clock(duration));
|
||||
let label = p.text(pos2(button.right() + 8.0, bar.center().y), Align2::LEFT_CENTER, time, FontId::new(11.5, egui::FontFamily::Monospace), theme::MUTED);
|
||||
let mute = Rect::from_center_size(pos2(bar.right() - 22.0, bar.center().y), vec2(28.0, 28.0));
|
||||
let mute_resp = ui.interact(mute, ui.id().with(("video-mute", &self.url)), Sense::click()).on_hover_text(if self.muted { "Unmute" } else { "Mute" }).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
icons::paint(&p, mute.shrink(7.0), if self.muted { Icon::VolumeOff } else { Icon::Volume }, theme::INK);
|
||||
if mute_resp.clicked() {
|
||||
self.muted = !self.muted;
|
||||
if let Some(sink) = self.stream.as_ref().and_then(|s| s.sink.as_ref()) {
|
||||
sink.set_volume(if self.muted { 0.0 } else { 1.0 });
|
||||
}
|
||||
}
|
||||
// The scrubber: click or drag to seek.
|
||||
let track = Rect::from_min_max(pos2(label.right() + 14.0, bar.center().y - 8.0), pos2(mute.left() - 12.0, bar.center().y + 8.0));
|
||||
let scrub = ui.interact(track, ui.id().with(("video-scrub", &self.url)), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let line = Rect::from_center_size(track.center(), vec2(track.width(), 4.0));
|
||||
p.rect_filled(line, egui::CornerRadius::same(2), theme::WASH_HOVER);
|
||||
let fraction = if duration > 0.0 { (self.position / duration).clamp(0.0, 1.0) as f32 } else { 0.0 };
|
||||
p.rect_filled(Rect::from_min_size(line.min, vec2(line.width() * fraction, line.height())), egui::CornerRadius::same(2), theme::ACCENT_400);
|
||||
if (scrub.clicked() || scrub.drag_stopped()) && duration > 0.0 {
|
||||
if let Some(pos) = scrub.interact_pointer_pos() {
|
||||
let t = (((pos.x - line.left()) / line.width()).clamp(0.0, 1.0) as f64) * duration;
|
||||
self.seek(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_probe(&mut self) {
|
||||
let Some(rx) = &self.probe else {
|
||||
return;
|
||||
};
|
||||
match rx.try_recv() {
|
||||
Ok(Ok(info)) => {
|
||||
self.info = Some(info);
|
||||
self.probe = None;
|
||||
self.play_from(0.0);
|
||||
},
|
||||
Ok(Err(err)) => {
|
||||
self.error = Some(err);
|
||||
self.probe = None;
|
||||
},
|
||||
Err(_) => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the newest frame that's due; loops at the end.
|
||||
fn advance(&mut self, ctx: &egui::Context) {
|
||||
let Some(stream) = &mut self.stream else {
|
||||
return;
|
||||
};
|
||||
let now = stream.start + stream.started.elapsed().as_secs_f64();
|
||||
let mut shown = None;
|
||||
loop {
|
||||
if stream.next.is_none() {
|
||||
match stream.frames.try_recv() {
|
||||
Ok(frame) => stream.next = Some(frame),
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
stream.ended = true;
|
||||
break;
|
||||
},
|
||||
Err(TryRecvError::Empty) => break,
|
||||
}
|
||||
}
|
||||
match &stream.next {
|
||||
Some((_, pts)) if *pts <= now => shown = stream.next.take(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if let Some((image, pts)) = shown {
|
||||
self.position = pts;
|
||||
match &mut self.texture {
|
||||
Some(t) => t.set(image, TextureOptions::LINEAR),
|
||||
None => self.texture = Some(ctx.load_texture(format!("video:{}", self.url), image, TextureOptions::LINEAR)),
|
||||
}
|
||||
}
|
||||
if stream.ended && stream.next.is_none() {
|
||||
self.play_from(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_pause(&mut self) {
|
||||
if self.stream.take().is_none() && self.info.is_some() {
|
||||
self.play_from(self.position);
|
||||
}
|
||||
}
|
||||
|
||||
fn seek(&mut self, t: f64) {
|
||||
self.position = t;
|
||||
self.play_from(t);
|
||||
}
|
||||
|
||||
fn play_from(&mut self, start: f64) {
|
||||
self.stream = None;
|
||||
let Some(info) = self.info else {
|
||||
return;
|
||||
};
|
||||
match self.spawn(info, start) {
|
||||
Ok(stream) => self.stream = Some(stream),
|
||||
Err(err) => self.error = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn(&mut self, info: VideoInfo, start: f64) -> Result<Stream, String> {
|
||||
let (w, h) = output_size(info);
|
||||
let mut video = command("ffmpeg").args(["-nostdin", "-loglevel", "error", "-ss", &format!("{start:.3}"), "-i", &self.url, "-an", "-vf", &format!("scale={w}:{h}"), "-f", "rawvideo", "-pix_fmt", "rgba", "-"]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|e| format!("ffmpeg: {e}"))?;
|
||||
let stdout = video.stdout.take().ok_or("ffmpeg has no output")?;
|
||||
let (frame_tx, frame_rx) = sync_channel(FRAME_QUEUE);
|
||||
let fps = info.fps.max(1.0);
|
||||
std::thread::spawn(move || read_frames(stdout, w, h, start, fps, frame_tx));
|
||||
let mut children = vec![video];
|
||||
let sink = self.spawn_audio(start, &mut children);
|
||||
Ok(Stream { start, started: Instant::now(), frames: frame_rx, next: None, ended: false, children, sink })
|
||||
}
|
||||
|
||||
/// The soundtrack as PCM into rodio (silently skipped when the clip has none).
|
||||
fn spawn_audio(&mut self, start: f64, children: &mut Vec<Child>) -> Option<Sink> {
|
||||
let mut audio = command("ffmpeg").args(["-nostdin", "-loglevel", "error", "-ss", &format!("{start:.3}"), "-i", &self.url, "-vn", "-ac", "2", "-ar", &AUDIO_RATE.to_string(), "-f", "s16le", "-"]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
|
||||
let mut stdout = audio.stdout.take()?;
|
||||
children.push(audio);
|
||||
if self.output.is_none() {
|
||||
self.output = OutputStream::try_default().map_err(|e| warn!("No audio output: {e}")).ok();
|
||||
}
|
||||
let (_, handle) = self.output.as_ref()?;
|
||||
let sink = Sink::try_new(handle).ok()?;
|
||||
let (tx, rx) = channel::<Vec<i16>>();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
while let Ok(n) = stdout.read(&mut buf) {
|
||||
if n == 0 || tx.send(buf[..n - n % 2].chunks_exact(2).map(|b| i16::from_le_bytes([b[0], b[1]])).collect()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
sink.set_volume(if self.muted { 0.0 } else { 1.0 });
|
||||
sink.append(PcmStream { rx, buf: VecDeque::new() });
|
||||
Some(sink)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads fixed-size RGBA frames from `ffmpeg` and stamps them with their media time.
|
||||
fn read_frames(mut stdout: impl Read, w: u32, h: u32, start: f64, fps: f64, tx: SyncSender<(ColorImage, f64)>) {
|
||||
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||
let mut n = 0u64;
|
||||
while stdout.read_exact(&mut buf).is_ok() {
|
||||
let image = ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &buf);
|
||||
if tx.send((image, start + n as f64 / fps)).is_err() {
|
||||
break;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Live PCM from a channel; silence while it waits, the end when the sender's gone.
|
||||
struct PcmStream {
|
||||
rx: Receiver<Vec<i16>>,
|
||||
buf: VecDeque<i16>,
|
||||
}
|
||||
|
||||
impl Iterator for PcmStream {
|
||||
type Item = i16;
|
||||
|
||||
fn next(&mut self) -> Option<i16> {
|
||||
if self.buf.is_empty() {
|
||||
match self.rx.try_recv() {
|
||||
Ok(chunk) => self.buf.extend(chunk),
|
||||
Err(TryRecvError::Empty) => return Some(0),
|
||||
Err(TryRecvError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.buf.pop_front().or(Some(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for PcmStream {
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn channels(&self) -> u16 {
|
||||
2
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> u32 {
|
||||
AUDIO_RATE
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the clip's size, rate and length. A small probe keeps this ~2 s on CDN files instead of
|
||||
/// ~14 s (ffprobe otherwise scans much of the file over HTTP).
|
||||
fn probe(url: &str) -> Result<VideoInfo, String> {
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct Probe {
|
||||
streams: Vec<ProbeStream>,
|
||||
format: ProbeFormat,
|
||||
}
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct ProbeStream {
|
||||
width: u32,
|
||||
height: u32,
|
||||
avg_frame_rate: String,
|
||||
}
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct ProbeFormat {
|
||||
duration: String,
|
||||
}
|
||||
let out = command("ffprobe").args(["-v", "error", "-probesize", "65536", "-analyzeduration", "0", "-select_streams", "v:0", "-show_entries", "stream=width,height,avg_frame_rate:format=duration", "-of", "json", url]).output().map_err(|e| format!("ffprobe: {e}"))?;
|
||||
let probe: Probe = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe: {e}"))?;
|
||||
let stream = probe.streams.first().ok_or("no video stream")?;
|
||||
let fps = match stream.avg_frame_rate.split_once('/') {
|
||||
Some((n, d)) => n.parse::<f64>().unwrap_or(24.0) / d.parse::<f64>().unwrap_or(1.0).max(1.0),
|
||||
None => stream.avg_frame_rate.parse().unwrap_or(24.0),
|
||||
};
|
||||
Ok(VideoInfo { width: stream.width.max(2), height: stream.height.max(2), fps: if fps.is_finite() && fps > 0.0 { fps } else { 24.0 }, duration: probe.format.duration.parse().unwrap_or(0.0) })
|
||||
}
|
||||
|
||||
/// The decoded size: at most `MAX_WIDTH` wide, both sides even (as `scale` requires).
|
||||
fn output_size(info: VideoInfo) -> (u32, u32) {
|
||||
let w = info.width.min(MAX_WIDTH) & !1;
|
||||
let h = ((info.height as f64 * w as f64 / info.width as f64).round() as u32).max(2) & !1;
|
||||
(w, h)
|
||||
}
|
||||
|
||||
/// `m:ss`.
|
||||
fn clock(secs: f64) -> String {
|
||||
let s = secs.max(0.0) as u64;
|
||||
format!("{}:{:02}", s / 60, s % 60)
|
||||
}
|
||||
|
||||
/// A command that doesn't flash a console window on Windows.
|
||||
fn command(program: &str) -> Command {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn output_sizes_are_even_and_capped() {
|
||||
let info = |width, height| VideoInfo { width, height, fps: 24.0, duration: 5.0 };
|
||||
assert_eq!(output_size(info(1920, 1080)), (1280, 720));
|
||||
assert_eq!(output_size(info(721, 1281)), (720, 1278));
|
||||
assert_eq!(clock(65.9), "1:05");
|
||||
}
|
||||
|
||||
/// Needs FFmpeg on the PATH: `cargo test -p artcraft_native -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn probes_and_decodes_a_generated_clip() {
|
||||
let dir = std::env::temp_dir().join("artcraft-native-video-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let clip = dir.join("clip.mp4");
|
||||
let status = command("ffmpeg").args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=2:size=640x360:rate=24", "-f", "lavfi", "-i", "sine=duration=2", "-shortest", "-pix_fmt", "yuv420p"]).arg(&clip).status().unwrap();
|
||||
assert!(status.success());
|
||||
let url = clip.to_string_lossy().into_owned();
|
||||
let info = probe(&url).unwrap();
|
||||
assert_eq!((info.width, info.height), (640, 360));
|
||||
assert!((info.fps - 24.0).abs() < 0.01 && (info.duration - 2.0).abs() < 0.2);
|
||||
|
||||
let mut player = VideoPlayer { url: url.clone(), probe: None, info: Some(info), stream: None, texture: None, position: 0.0, muted: true, error: None, output: None };
|
||||
let stream = player.spawn(info, 1.0).unwrap();
|
||||
let (frame, pts) = stream.frames.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(frame.size, [640, 360]);
|
||||
assert!((pts - 1.0).abs() < 1e-9, "frames are stamped from the start position");
|
||||
let count = 1 + stream.frames.iter().count();
|
||||
assert!((20..=28).contains(&count), "about a second of frames at 24 fps, got {count}");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,17 @@ impl MediaLinks {
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaLinks {
|
||||
/// A video's animated preview, `width` pixels wide where the CDN can resize.
|
||||
pub fn animated_preview(&self, width: u32) -> Option<String> {
|
||||
let previews = self.maybe_video_previews.as_ref()?;
|
||||
if !previews.animated_thumbnail_template.is_empty() {
|
||||
return Some(previews.animated_thumbnail_template.replace("{WIDTH}", &width.to_string()));
|
||||
}
|
||||
(!previews.animated.is_empty()).then(|| previews.animated.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/media_files/list/user/{username}`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -194,6 +205,36 @@ pub struct ContextImage {
|
||||
pub media_links: MediaLinks,
|
||||
}
|
||||
|
||||
/// A saved character (`GET /v1/characters/session`).
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Character {
|
||||
pub token: String,
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
pub maybe_avatar: Option<MediaLinks>,
|
||||
pub maybe_full_image: Option<MediaLinks>,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
impl Character {
|
||||
pub fn avatar_url(&self) -> Option<String> {
|
||||
self.maybe_avatar.as_ref().or(self.maybe_full_image.as_ref()).and_then(|l| l.thumbnail(256))
|
||||
}
|
||||
|
||||
pub fn full_url(&self) -> Option<String> {
|
||||
self.maybe_full_image.as_ref().or(self.maybe_avatar.as_ref()).map(|l| l.cdn_url.clone()).filter(|u| !u.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CharactersPage {
|
||||
pub success: bool,
|
||||
pub characters: Vec<Character>,
|
||||
pub next_cursor: Option<i64>,
|
||||
}
|
||||
|
||||
/// `GET /v1/session` (only the parts the app shows).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -61,6 +61,8 @@ pub struct FeedView<'a> {
|
||||
/// Prompt texts by prompt token (list view and pending cards).
|
||||
pub prompts: &'a HashMap<String, String>,
|
||||
pub make_video: bool,
|
||||
/// Play videos' animated previews instead of their still frames.
|
||||
pub autoplay: bool,
|
||||
pub bottom_padding: f32,
|
||||
}
|
||||
|
||||
@@ -230,7 +232,13 @@ fn item_card(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cach
|
||||
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)) {
|
||||
// Videos play their animated preview when previews are on (the still shows while it loads).
|
||||
let animated = item.animated.as_deref().filter(|_| view.autoplay && item.kind == MediaKind::Video).map(|u| cache.get_animated(ui.ctx(), u));
|
||||
let lookup = match animated {
|
||||
Some(Lookup::Ready(t)) => Some(Lookup::Ready(t)),
|
||||
_ => item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)),
|
||||
};
|
||||
match lookup {
|
||||
Some(Lookup::Ready(t)) => {
|
||||
let size = t.size_vec2();
|
||||
if size.x > 0.0 && !ratios.0.contains_key(&item.token) {
|
||||
@@ -241,7 +249,7 @@ fn item_card(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cach
|
||||
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 {
|
||||
if item.kind == MediaKind::Video && !(view.autoplay && item.animated.is_some()) {
|
||||
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));
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::models::Catalog;
|
||||
const FAILURE_WINDOW_SECS: i64 = 24 * 60 * 60;
|
||||
/// Thumbnails are requested at this width (`getMediaThumbnail(@512)`).
|
||||
pub const THUMBNAIL_WIDTH: u32 = 512;
|
||||
/// Animated previews are requested smaller: every frame becomes a texture.
|
||||
const ANIMATED_WIDTH: u32 = 320;
|
||||
|
||||
/// Something the store wants the app to do or say after an update.
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -103,7 +105,7 @@ impl FeedStore {
|
||||
};
|
||||
// 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 });
|
||||
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, animated: result.media_links.animated_preview(ANIMATED_WIDTH) });
|
||||
if let Some(batch) = &result.maybe_batch_token {
|
||||
notices.push(FeedNotice::LoadBatch { job_token: job.job_token.clone(), batch_token: batch.clone() });
|
||||
}
|
||||
@@ -216,6 +218,7 @@ pub fn feed_item(kind: MediaKind, file: &MediaFile) -> FeedItem {
|
||||
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),
|
||||
animated: file.media_links.animated_preview(ANIMATED_WIDTH),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct FeedItem {
|
||||
pub prompt_token: Option<String>,
|
||||
pub batch_token: Option<String>,
|
||||
pub duration_secs: Option<f32>,
|
||||
/// A video's animated preview (shown when previews play).
|
||||
pub animated: Option<String>,
|
||||
}
|
||||
|
||||
/// A generation the server is still working on.
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct ModelInfo {
|
||||
pub video_refs_max_secs: Option<u16>,
|
||||
pub audio_refs_max: usize,
|
||||
pub audio_refs_max_secs: Option<u16>,
|
||||
/// `@Character` mentions (0 = not supported).
|
||||
pub character_refs_max: usize,
|
||||
pub duration_options: Vec<u16>,
|
||||
pub duration_min: Option<u16>,
|
||||
pub duration_max: Option<u16>,
|
||||
@@ -162,6 +164,7 @@ pub fn video_model(d: &artcraft_client::endpoints::omni_gen::models::video::omni
|
||||
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.character_refs_max = max(d.character_references_supported, d.character_references_max);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! The characters dialog (`CharactersModal`): pick a saved character to mention it in the prompt,
|
||||
//! make a new one from a reference image, rename or re-describe one, or delete it.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
|
||||
use crate::backend::UploadedMedia;
|
||||
use crate::backend::media_cache::{Lookup, MediaCache};
|
||||
use crate::backend::wire::Character;
|
||||
use crate::prompt_box::deck::cover_uv;
|
||||
use crate::prompt_box::refs::{RefKind, RefMedia, RefStatus};
|
||||
use crate::theme;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::widgets;
|
||||
|
||||
const COLUMNS: usize = 4;
|
||||
const GAP: f32 = 12.0;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum View {
|
||||
List,
|
||||
Edit(String),
|
||||
Create,
|
||||
}
|
||||
|
||||
pub struct CharactersModal {
|
||||
view: View,
|
||||
confirm_delete: Option<(String, String)>,
|
||||
name: String,
|
||||
description: String,
|
||||
image: Option<RefMedia>,
|
||||
}
|
||||
|
||||
/// Something only the app can do for the dialog.
|
||||
#[derive(Debug)]
|
||||
pub enum CharactersAction {
|
||||
Close,
|
||||
/// Mention this character in the prompt.
|
||||
Select {
|
||||
token: String,
|
||||
name: String,
|
||||
},
|
||||
Upload {
|
||||
ref_id: u64,
|
||||
path: PathBuf,
|
||||
},
|
||||
Create {
|
||||
image_token: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
},
|
||||
Edit {
|
||||
token: String,
|
||||
name: String,
|
||||
description: String,
|
||||
},
|
||||
Delete {
|
||||
token: String,
|
||||
name: String,
|
||||
},
|
||||
Preview(String),
|
||||
Toast(String),
|
||||
}
|
||||
|
||||
impl CharactersModal {
|
||||
pub fn new() -> Self {
|
||||
Self { view: View::List, confirm_delete: None, name: String::new(), description: String::new(), image: None }
|
||||
}
|
||||
|
||||
/// The reference image being uploaded for a new character, if `ref_id` is it.
|
||||
pub fn apply_upload(&mut self, ref_id: u64, result: Result<UploadedMedia, String>) -> Option<Result<(), String>> {
|
||||
let image = self.image.as_mut().filter(|i| i.id == ref_id)?;
|
||||
Some(match result {
|
||||
Ok(media) => {
|
||||
image.status = RefStatus::Ready;
|
||||
image.token = Some(media.token);
|
||||
image.full_url = media.full_url;
|
||||
Ok(())
|
||||
},
|
||||
Err(err) => {
|
||||
self.image = None;
|
||||
Err(err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, characters: &[Character], pending: &[String], cache: &mut MediaCache) -> Vec<CharactersAction> {
|
||||
let mut actions = Vec::new();
|
||||
let width = (ctx.content_rect().width() - 80.0).min(760.0);
|
||||
let close = widgets::modal(ctx, Id::new("characters"), width, |ui| match self.view.clone() {
|
||||
View::List => self.list(ui, characters, pending, cache, &mut actions),
|
||||
View::Edit(token) => match characters.iter().find(|c| c.token == token) {
|
||||
Some(c) => self.edit(ui, c, cache, &mut actions),
|
||||
None => self.view = View::List,
|
||||
},
|
||||
View::Create => self.create(ui, cache, &mut actions),
|
||||
});
|
||||
if close && self.confirm_delete.is_none() {
|
||||
actions.push(CharactersAction::Close);
|
||||
}
|
||||
if let Some((token, name)) = self.confirm_delete.clone() {
|
||||
let mut choice = None;
|
||||
let dismissed = widgets::modal(ctx, Id::new("character-delete"), 420.0, |ui| {
|
||||
ui.label(widgets::heading("Delete character?", 20.0));
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(format!("\u{201c}{name}\u{201d} will be removed from your characters.")).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 dismissed || choice.is_some() {
|
||||
self.confirm_delete = None;
|
||||
}
|
||||
if choice == Some(true) {
|
||||
actions.push(CharactersAction::Delete { token, name });
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn header(&mut self, ui: &mut Ui, title: &str, back: bool, actions: &mut Vec<CharactersAction>) {
|
||||
ui.horizontal(|ui| {
|
||||
if back && widgets::ghost(ui, Icon::ChevronLeft, None, "Back", false).clicked() {
|
||||
self.view = View::List;
|
||||
}
|
||||
ui.label(widgets::heading(title, 22.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
|
||||
actions.push(CharactersAction::Close);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
|
||||
fn list(&mut self, ui: &mut Ui, characters: &[Character], pending: &[String], cache: &mut MediaCache, actions: &mut Vec<CharactersAction>) {
|
||||
self.header(ui, "Characters", false, actions);
|
||||
let tile = (ui.available_width() - GAP * (COLUMNS - 1) as f32) / COLUMNS as f32;
|
||||
let max_h = ui.ctx().content_rect().height() * 0.6;
|
||||
egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let total = 1 + pending.len() + characters.len();
|
||||
let rows = total.div_ceil(COLUMNS);
|
||||
let origin = ui.cursor().min;
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), rows as f32 * (tile + 30.0 + GAP)), Sense::hover());
|
||||
let cell = |i: usize| Rect::from_min_size(origin + vec2((i % COLUMNS) as f32 * (tile + GAP), (i / COLUMNS) as f32 * (tile + 30.0 + GAP)), vec2(tile, tile));
|
||||
// "Create New" first.
|
||||
let create = cell(0);
|
||||
let resp = ui.interact(create, Id::new("character-create"), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
ui.painter().rect_filled(create, theme::RADIUS, theme::fade(theme::CONTROLS, if resp.hovered() { 0.7 } else { 0.4 }));
|
||||
icons::dashed_rect(ui.painter(), create.shrink(1.0), Stroke::new(1.5, theme::LINE_DASHED), 5.0, 4.0);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(create.center() - vec2(0.0, 10.0), vec2(22.0, 22.0)), Icon::Plus, theme::INK);
|
||||
ui.painter().text(create.center() + vec2(0.0, 16.0), Align2::CENTER_CENTER, "Create New", FontId::new(13.0, theme::medium()), theme::INK);
|
||||
if resp.clicked() {
|
||||
self.name.clear();
|
||||
self.description.clear();
|
||||
self.image = None;
|
||||
self.view = View::Create;
|
||||
}
|
||||
for (i, name) in pending.iter().enumerate() {
|
||||
let r = cell(1 + i);
|
||||
ui.painter().rect_filled(r, theme::RADIUS, theme::WASH);
|
||||
icons::paint_spinner(ui, Rect::from_center_size(r.center(), vec2(24.0, 24.0)), theme::MUTED);
|
||||
caption(ui, r, name, "Creating...");
|
||||
}
|
||||
for (i, c) in characters.iter().enumerate() {
|
||||
let r = cell(1 + pending.len() + i);
|
||||
let resp = ui.interact(r, Id::new(("character", &c.token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
thumbnail(ui, r, c.avatar_url().as_deref(), cache);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(r, theme::RADIUS, Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.7)), egui::StrokeKind::Inside);
|
||||
}
|
||||
caption(ui, r, &c.name, "");
|
||||
let mut clicked_tool = false;
|
||||
if ui.rect_contains_pointer(r) {
|
||||
let tools = Rect::from_min_size(pos2(r.right() - 64.0, r.top() + 6.0), vec2(58.0, 28.0));
|
||||
ui.painter().rect_filled(tools, theme::RADIUS, Color32::from_black_alpha(160));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(tools.shrink(1.0)).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.spacing_mut().item_spacing.x = 0.0;
|
||||
if widgets::overlay_icon(&mut child, Icon::Pencil, "Edit character").clicked() {
|
||||
self.name = c.name.clone();
|
||||
self.description = c.maybe_description.clone().unwrap_or_default();
|
||||
self.view = View::Edit(c.token.clone());
|
||||
clicked_tool = true;
|
||||
}
|
||||
if widgets::overlay_icon(&mut child, Icon::Trash, "Delete character").clicked() {
|
||||
self.confirm_delete = Some((c.token.clone(), c.name.clone()));
|
||||
clicked_tool = true;
|
||||
}
|
||||
}
|
||||
if resp.clicked() && !clicked_tool {
|
||||
actions.push(CharactersAction::Select { token: c.token.clone(), name: c.name.clone() });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn edit(&mut self, ui: &mut Ui, c: &Character, cache: &mut MediaCache, actions: &mut Vec<CharactersAction>) {
|
||||
self.header(ui, "Edit Character", true, actions);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());
|
||||
thumbnail(ui, r, c.full_url().or_else(|| c.avatar_url()).as_deref(), cache);
|
||||
if let Some(url) = c.full_url() {
|
||||
if ui.add(egui::Button::new(egui::RichText::new("View full size").size(12.0).color(theme::ACCENT_INK)).frame(false)).clicked() {
|
||||
actions.push(CharactersAction::Preview(url));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| {
|
||||
fields(ui, &mut self.name, &mut self.description);
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::button(ui, None, "Save", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() {
|
||||
if self.name.trim().is_empty() {
|
||||
actions.push(CharactersAction::Toast("Name cannot be empty".to_owned()));
|
||||
} else {
|
||||
actions.push(CharactersAction::Edit { token: c.token.clone(), name: self.name.trim().to_owned(), description: self.description.trim().to_owned() });
|
||||
self.view = View::List;
|
||||
}
|
||||
}
|
||||
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
|
||||
self.view = View::List;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn create(&mut self, ui: &mut Ui, cache: &mut MediaCache, actions: &mut Vec<CharactersAction>) {
|
||||
self.header(ui, "New Character", true, actions);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
widgets::hud(ui, "Reference", theme::FAINT);
|
||||
ui.add_space(6.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::click());
|
||||
match &self.image {
|
||||
Some(img) => {
|
||||
thumbnail(ui, r, img.preview.as_deref(), cache);
|
||||
if img.status == RefStatus::Uploading {
|
||||
ui.painter().rect_filled(r, theme::RADIUS, Color32::from_black_alpha(110));
|
||||
icons::paint_spinner(ui, Rect::from_center_size(r.center(), vec2(28.0, 28.0)), Color32::WHITE);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
ui.painter().rect_filled(r, theme::RADIUS, theme::fade(theme::CONTROLS, if resp.hovered() { 0.7 } else { 0.4 }));
|
||||
icons::dashed_rect(ui.painter(), r.shrink(1.0), Stroke::new(1.5, theme::LINE_DASHED), 5.0, 4.0);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 12.0), vec2(24.0, 24.0)), Icon::Upload, theme::INK);
|
||||
ui.painter().text(r.center() + vec2(0.0, 18.0), Align2::CENTER_CENTER, "Upload reference image", FontId::new(13.0, theme::medium()), theme::INK);
|
||||
},
|
||||
}
|
||||
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
if let Some(path) = crate::pages::common::pick_files(&[RefKind::Image], false).into_iter().next() {
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let mut img = RefMedia::uploading(RefKind::Image, None);
|
||||
let key = format!("local://{}", img.id);
|
||||
cache.insert_bytes(ui.ctx(), &key, Arc::from(bytes));
|
||||
img.preview = Some(key);
|
||||
actions.push(CharactersAction::Upload { ref_id: img.id, path });
|
||||
self.image = Some(img);
|
||||
},
|
||||
Err(err) => actions.push(CharactersAction::Toast(format!("Failed to upload {}: {err}", crate::pages::common::file_name(&path)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| {
|
||||
fields(ui, &mut self.name, &mut self.description);
|
||||
ui.add_space(16.0);
|
||||
let uploading = self.image.as_ref().is_some_and(|i| i.status == RefStatus::Uploading);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled_ui(!uploading, |ui| widgets::button(ui, Some(Icon::Sparkles), "Create", widgets::Kind::Primary, 0.0, theme::CONTROL_H)).inner.clicked() {
|
||||
match (self.name.trim(), self.image.as_ref().and_then(|i| i.token.clone())) {
|
||||
("", _) => actions.push(CharactersAction::Toast("Please enter a character name".to_owned())),
|
||||
(_, None) => actions.push(CharactersAction::Toast("Please upload a reference image".to_owned())),
|
||||
(name, Some(image_token)) => {
|
||||
let description = Some(self.description.trim().to_owned()).filter(|d| !d.is_empty());
|
||||
actions.push(CharactersAction::Create { image_token, name: name.to_owned(), description });
|
||||
self.view = View::List;
|
||||
},
|
||||
}
|
||||
}
|
||||
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
|
||||
self.view = View::List;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The name and description inputs shared by create and edit.
|
||||
fn fields(ui: &mut Ui, name: &mut String, description: &mut String) {
|
||||
widgets::hud(ui, "Character name", theme::FAINT);
|
||||
ui.add_space(4.0);
|
||||
input_frame(ui, |ui| ui.add(egui::TextEdit::singleline(name).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new("Character name").color(theme::FAINT))));
|
||||
ui.add_space(10.0);
|
||||
widgets::hud(ui, "Description", theme::FAINT);
|
||||
ui.add_space(4.0);
|
||||
input_frame(ui, |ui| ui.add(egui::TextEdit::multiline(description).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(4).hint_text(egui::RichText::new("Description...").color(theme::FAINT))));
|
||||
}
|
||||
|
||||
fn input_frame(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> egui::Response) {
|
||||
egui::Frame::new().fill(theme::SUNKEN).stroke(theme::hairline()).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| add(ui));
|
||||
}
|
||||
|
||||
fn thumbnail(ui: &Ui, rect: Rect, url: Option<&str>, cache: &mut MediaCache) {
|
||||
ui.painter().rect_filled(rect, theme::RADIUS, theme::WASH);
|
||||
match url.map(|u| cache.get(ui.ctx(), u)) {
|
||||
Some(Lookup::Ready(t)) => {
|
||||
egui::Image::new(&t).uv(cover_uv(t.size_vec2())).corner_radius(theme::RADIUS).paint_at(ui, rect);
|
||||
},
|
||||
Some(Lookup::Loading) => icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(20.0, 20.0)), theme::FAINT),
|
||||
_ => icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(28.0, 28.0)), Icon::User, theme::FAINT),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name under a tile (and a muted note after it).
|
||||
fn caption(ui: &Ui, tile: Rect, name: &str, note: &str) {
|
||||
let y = tile.bottom() + 15.0;
|
||||
let galley = ui.painter().layout_no_wrap(name.to_owned(), FontId::new(13.0, theme::medium()), theme::INK);
|
||||
let w = galley.size().x.min(tile.width());
|
||||
ui.painter().with_clip_rect(Rect::from_min_size(pos2(tile.left(), y - 10.0), vec2(tile.width(), 20.0))).galley(pos2(tile.left(), y - galley.size().y / 2.0), galley, theme::INK);
|
||||
if !note.is_empty() {
|
||||
ui.painter().text(pos2(tile.left() + w + 6.0, y), Align2::LEFT_CENTER, note, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
use egui::{Align2, Color32, FontId, Id, Key, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
use crate::backend::media_cache::{Lookup, MediaCache};
|
||||
use crate::backend::video::{self, VideoPlayer};
|
||||
use crate::backend::wire::Prompt;
|
||||
use crate::feed::grid::ModelNames;
|
||||
use crate::feed::types::{FeedItem, MediaKind};
|
||||
@@ -18,6 +19,8 @@ pub struct Lightbox {
|
||||
pub token: String,
|
||||
confirm_delete: bool,
|
||||
prompt_expanded: bool,
|
||||
/// The in-app player for videos (when FFmpeg is installed).
|
||||
player: Option<VideoPlayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -35,7 +38,7 @@ pub enum LightboxAction {
|
||||
|
||||
impl Lightbox {
|
||||
pub fn new(kind: MediaKind, token: String) -> Self {
|
||||
Self { kind, token, confirm_delete: false, prompt_expanded: false }
|
||||
Self { kind, token, confirm_delete: false, prompt_expanded: false, player: None }
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, item: &FeedItem, order: &[String], prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache) -> Vec<LightboxAction> {
|
||||
@@ -80,7 +83,22 @@ impl Lightbox {
|
||||
}
|
||||
|
||||
#[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>) {
|
||||
fn media(&mut self, ui: &mut Ui, rect: Rect, item: &FeedItem, cache: &mut MediaCache, prev: &Option<String>, next: &Option<String>, actions: &mut Vec<LightboxAction>) {
|
||||
if item.kind == MediaKind::Video && video::ffmpeg_available() {
|
||||
if self.player.as_ref().is_none_or(|p| p.url() != item.full_url) {
|
||||
self.player = Some(VideoPlayer::new(&item.full_url));
|
||||
}
|
||||
let poster = match item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
|
||||
Some(Lookup::Ready(t)) => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(player) = &mut self.player {
|
||||
player.ui(ui, rect, poster.as_ref());
|
||||
}
|
||||
self.nav_buttons(ui, rect, prev, next, actions);
|
||||
return;
|
||||
}
|
||||
self.player = None;
|
||||
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)) {
|
||||
@@ -109,6 +127,11 @@ impl Lightbox {
|
||||
actions.push(LightboxAction::Play(item.full_url.clone()));
|
||||
}
|
||||
}
|
||||
self.nav_buttons(ui, rect, prev, next, actions);
|
||||
}
|
||||
|
||||
/// Previous / next arrows at the media's sides (shown while hovering it).
|
||||
fn nav_buttons(&self, ui: &mut Ui, rect: Rect, prev: &Option<String>, next: &Option<String>, actions: &mut Vec<LightboxAction>) {
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod account;
|
||||
pub mod characters;
|
||||
pub mod library_picker;
|
||||
pub mod lightbox;
|
||||
|
||||
@@ -34,23 +34,48 @@ pub struct Env<'a> {
|
||||
pub signed_in: bool,
|
||||
pub enter_to_generate: bool,
|
||||
pub view_mode: ViewMode,
|
||||
pub autoplay: bool,
|
||||
pub ratios: &'a mut RatioCache,
|
||||
pub prompts: &'a std::collections::HashMap<String, String>,
|
||||
pub requests: &'a mut Vec<AppRequest>,
|
||||
/// The user's characters, newest first.
|
||||
pub characters: &'a [crate::backend::wire::Character],
|
||||
}
|
||||
|
||||
/// 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 },
|
||||
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),
|
||||
/// Play or stop an audio reference.
|
||||
ToggleAudio {
|
||||
ref_id: u64,
|
||||
url: String,
|
||||
},
|
||||
/// Open a file in the system's player.
|
||||
PlayExternally(String),
|
||||
/// Open the characters dialog (it mentions the picked one in the video prompt).
|
||||
OpenCharacters,
|
||||
LoadMoreLibrary(FeedKey),
|
||||
}
|
||||
|
||||
@@ -87,7 +112,7 @@ pub fn create_shell(ui: &mut Ui, env: &mut Env<'_>, kind: MediaKind, title: &str
|
||||
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 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, autoplay: env.autoplay, 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);
|
||||
|
||||
@@ -143,6 +143,7 @@ impl CreateImagePage {
|
||||
DeckAction::SwapFrames => {},
|
||||
},
|
||||
PromptBoxAction::DroppedFiles(paths) => self.add_dropped(env, &paths, max_images),
|
||||
PromptBoxAction::MentionPicked(..) => {},
|
||||
PromptBoxAction::PastedImage(png) => {
|
||||
if self.refs.images.len() >= max_images {
|
||||
env.toasts.error(max_images_message(max_images));
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct CreateVideoPage {
|
||||
pub generating: bool,
|
||||
box_state: PromptBoxState,
|
||||
box_height: f32,
|
||||
/// Which character each mentioned name means, when the user picked one explicitly.
|
||||
character_picks: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CreateVideoPage {
|
||||
@@ -104,7 +106,11 @@ impl CreateVideoPage {
|
||||
(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 characters_on = model.is_some_and(supports_characters);
|
||||
let mut mentions = if reference { self.mentions() } else { Vec::new() };
|
||||
if characters_on {
|
||||
mentions.extend(editor::character_mentions(env.characters.iter().map(|c| (c.name.as_str(), c.token.as_str(), c.avatar_url()))));
|
||||
}
|
||||
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 };
|
||||
@@ -116,12 +122,16 @@ impl CreateVideoPage {
|
||||
let with_refs = reference && !self.refs.images.is_empty();
|
||||
let mut picked_model = None;
|
||||
let mut picked_mode = None;
|
||||
let mut open_characters = false;
|
||||
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);
|
||||
if characters_on && crate::prompt_box::pickers::toggle(ui, Id::new("video-characters"), Icon::User, "@Characters", false, "Characters") {
|
||||
open_characters = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut picked_count = None;
|
||||
@@ -141,6 +151,9 @@ impl CreateVideoPage {
|
||||
if let Some(mode) = picked_mode {
|
||||
switch_mode(&mut self.settings, &mut self.refs, mode);
|
||||
}
|
||||
if open_characters {
|
||||
env.requests.push(AppRequest::OpenCharacters);
|
||||
}
|
||||
for action in actions {
|
||||
self.handle(action, env, model, limits.as_ref());
|
||||
}
|
||||
@@ -176,14 +189,27 @@ impl CreateVideoPage {
|
||||
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::Preview(id) => match self.refs.find_mut(id) {
|
||||
// Audio cards play and stop on click (the webapp's DeckCard).
|
||||
Some(r) if r.kind == RefKind::Audio => {
|
||||
if let Some(url) = r.full_url.clone() {
|
||||
env.requests.push(AppRequest::ToggleAudio { ref_id: id, url });
|
||||
}
|
||||
},
|
||||
Some(r) => {
|
||||
if let Some(url) = r.full_url.clone().or_else(|| r.preview.clone()) {
|
||||
env.requests.push(if r.kind == RefKind::Video { AppRequest::PlayExternally(url) } else { AppRequest::Preview(url) });
|
||||
}
|
||||
},
|
||||
None => {},
|
||||
},
|
||||
DeckAction::SwapFrames => self.refs.swap_frames(),
|
||||
},
|
||||
PromptBoxAction::DroppedFiles(paths) => self.add_files(env, &paths, ImageSlot::Reference, limits),
|
||||
PromptBoxAction::MentionPicked(label, Some(token)) => {
|
||||
self.character_picks.insert(label.trim_start_matches('@').to_owned(), token);
|
||||
},
|
||||
PromptBoxAction::MentionPicked(_, None) => {},
|
||||
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),
|
||||
@@ -288,7 +314,27 @@ impl CreateVideoPage {
|
||||
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);
|
||||
let mut fields = self.fields(model, false);
|
||||
if supports_characters(model) {
|
||||
let tokens = mentioned_characters(&self.settings.prompt, env.characters, &self.character_picks);
|
||||
if !tokens.is_empty() {
|
||||
fields.insert("reference_character_tokens".into(), json!(tokens));
|
||||
}
|
||||
}
|
||||
env.backend.generate(Modality::Video, fields, meta);
|
||||
}
|
||||
|
||||
/// The characters dialog picked `name`: mention it at the end of the prompt.
|
||||
pub fn mention_character(&mut self, name: &str, token: &str) {
|
||||
let p = &mut self.settings.prompt;
|
||||
if !p.is_empty() && !p.ends_with(char::is_whitespace) {
|
||||
p.push(' ');
|
||||
}
|
||||
p.push('@');
|
||||
p.push_str(name);
|
||||
p.push(' ');
|
||||
self.character_picks.insert(name.to_owned(), token.to_owned());
|
||||
self.box_state.editor.focus_requested = true;
|
||||
}
|
||||
|
||||
/// The OmniGen request (`GenerateVideoRequest` after `api_fields`).
|
||||
@@ -506,6 +552,33 @@ fn switch_mode(s: &mut VideoSettings, refs: &mut References, mode: InputMode) {
|
||||
s.input_mode = mode;
|
||||
}
|
||||
|
||||
/// Whether the model takes `@Character` mentions (the listing says so; Seedance 2.0 always does).
|
||||
fn supports_characters(m: &ModelInfo) -> bool {
|
||||
m.character_refs_max > 0 || m.id == "seedance_2p0"
|
||||
}
|
||||
|
||||
/// One token per character named in the prompt (`@Name`, not inside a longer word): the user's
|
||||
/// explicit pick for that name, else the newest character with it.
|
||||
fn mentioned_characters(prompt: &str, characters: &[crate::backend::wire::Character], picks: &std::collections::HashMap<String, String>) -> Vec<String> {
|
||||
let mut names: Vec<&str> = characters.iter().map(|c| c.name.as_str()).filter(|n| !n.is_empty()).collect();
|
||||
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
|
||||
names.dedup();
|
||||
let mut tokens = Vec::new();
|
||||
for name in names {
|
||||
let needle = format!("@{name}");
|
||||
let mentioned = prompt.match_indices(&needle).any(|(i, _)| !prompt[i + needle.len()..].chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_'));
|
||||
if !mentioned {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<&crate::backend::wire::Character> = characters.iter().filter(|c| c.name == name).collect();
|
||||
let chosen = picks.get(name).and_then(|t| candidates.iter().find(|c| &c.token == t)).or(candidates.first());
|
||||
if let Some(c) = chosen {
|
||||
tokens.push(c.token.clone());
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -539,3 +612,25 @@ fn frames_full_message(model: Option<&ModelInfo>) -> &'static str {
|
||||
"The first frame is already set"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::wire::Character;
|
||||
|
||||
fn character(token: &str, name: &str) -> Character {
|
||||
Character { token: token.into(), name: name.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mentions_resolve_to_one_token_per_name() {
|
||||
// Newest first, as the server lists them.
|
||||
let characters = [character("c3", "Bob"), character("c2", "Bob2"), character("c1", "Bob"), character("c0", "Ann")];
|
||||
let none = std::collections::HashMap::new();
|
||||
assert_eq!(mentioned_characters("@Bob2 waves", &characters, &none), ["c2"], "@Bob2 isn't @Bob");
|
||||
assert_eq!(mentioned_characters("@Bob, then @Ann.", &characters, &none), ["c3", "c0"], "newest Bob by default");
|
||||
let picks = std::collections::HashMap::from([("Bob".to_owned(), "c1".to_owned())]);
|
||||
assert_eq!(mentioned_characters("hi @Bob", &characters, &picks), ["c1"], "the explicit pick wins");
|
||||
assert!(mentioned_characters("no mentions", &characters, &none).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub fn library(ui: &mut Ui, env: &mut Env<'_>, feed: &mut FeedStore) {
|
||||
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 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, autoplay: env.autoplay, 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 {
|
||||
|
||||
@@ -309,12 +309,14 @@ fn paint_card(ui: &Ui, painter: &egui::Painter, rect: Rect, tilt: f32, item: &Re
|
||||
image.paint_at(ui, rect.shrink(2.0));
|
||||
},
|
||||
None => {
|
||||
let playing = item.kind == RefKind::Audio && playing_audio(ui) == Some(item.id);
|
||||
let icon = match item.kind {
|
||||
RefKind::Image => Icon::Image,
|
||||
RefKind::Video => Icon::Video,
|
||||
RefKind::Audio => Icon::Music,
|
||||
RefKind::Audio if playing => Icon::Volume,
|
||||
RefKind::Audio => Icon::Play,
|
||||
};
|
||||
icons::paint(painter, Rect::from_center_size(rect.center(), vec2(20.0, 20.0)), icon, theme::MUTED);
|
||||
icons::paint(painter, Rect::from_center_size(rect.center(), vec2(20.0, 20.0)), icon, if playing { theme::ACCENT_INK } else { theme::MUTED });
|
||||
},
|
||||
}
|
||||
if item.kind != RefKind::Image && tilt == 0.0 {
|
||||
@@ -334,6 +336,17 @@ fn paint_card(ui: &Ui, painter: &egui::Painter, rect: Rect, tilt: f32, item: &Re
|
||||
painter.add(Shape::closed_line(corners.to_vec(), Stroke::new(2.0, border)));
|
||||
}
|
||||
|
||||
/// The audio reference playing right now (the app sets it each frame).
|
||||
pub fn set_playing_audio(ctx: &egui::Context, id: Option<u64>) {
|
||||
ctx.data_mut(|d| d.insert_temp(Id::new(PLAYING_AUDIO), id));
|
||||
}
|
||||
|
||||
fn playing_audio(ui: &Ui) -> Option<u64> {
|
||||
ui.data(|d| d.get_temp::<Option<u64>>(Id::new(PLAYING_AUDIO))).flatten()
|
||||
}
|
||||
|
||||
const PLAYING_AUDIO: &str = "deck-playing-audio";
|
||||
|
||||
/// 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));
|
||||
|
||||
@@ -22,12 +22,15 @@ 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)];
|
||||
/// Teal, emerald, sky.
|
||||
const CHARACTER_COLORS: [Color32; 3] = [Color32::from_rgb(45, 212, 191), Color32::from_rgb(34, 197, 94), Color32::from_rgb(14, 165, 233)];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MentionKind {
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
Character,
|
||||
}
|
||||
|
||||
/// Something the prompt can reference by name, e.g. `@Image2`.
|
||||
@@ -39,6 +42,8 @@ pub struct MentionItem {
|
||||
pub index: usize,
|
||||
/// Media cache key of a thumbnail, if there is one.
|
||||
pub preview: Option<String>,
|
||||
/// The character this mention names (several can share a name).
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl MentionItem {
|
||||
@@ -48,6 +53,7 @@ impl MentionItem {
|
||||
MentionKind::Image => IMAGE_COLORS[i % IMAGE_COLORS.len()],
|
||||
MentionKind::Video => VIDEO_COLORS[i % VIDEO_COLORS.len()],
|
||||
MentionKind::Audio => AUDIO_COLORS[i % AUDIO_COLORS.len()],
|
||||
MentionKind::Character => CHARACTER_COLORS[i % CHARACTER_COLORS.len()],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +99,8 @@ pub struct EditorOutput {
|
||||
pub submit: bool,
|
||||
pub open_fullscreen: bool,
|
||||
pub focused: bool,
|
||||
/// A mention the user picked from the dropdown: (label, character token).
|
||||
pub picked: Option<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
/// Draws the editor in the available width.
|
||||
@@ -105,7 +113,7 @@ pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &Ed
|
||||
// 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);
|
||||
out.picked = handle_dropdown_keys(ui, id, prompt, state, &filtered);
|
||||
} else if opts.enter_to_generate && consume_plain_enter(ui) {
|
||||
out.submit = true;
|
||||
}
|
||||
@@ -183,7 +191,9 @@ pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &Ed
|
||||
}
|
||||
|
||||
if !filtered.is_empty() && out.focused {
|
||||
mention_dropdown(ui, id, field_rect, prompt, state, &filtered, thumbnails);
|
||||
if let Some(picked) = mention_dropdown(ui, id, field_rect, prompt, state, &filtered, thumbnails) {
|
||||
out.picked = Some(picked);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -214,12 +224,10 @@ fn consume_plain_enter(ui: &Ui) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_dropdown_keys(ui: &Ui, id: Id, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem]) {
|
||||
fn handle_dropdown_keys(ui: &Ui, id: Id, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem]) -> Option<(String, Option<String>)> {
|
||||
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;
|
||||
};
|
||||
let query = state.mention.as_mut()?;
|
||||
if down {
|
||||
query.selected = (query.selected + 1) % n;
|
||||
}
|
||||
@@ -228,12 +236,14 @@ fn handle_dropdown_keys(ui: &Ui, id: Id, prompt: &mut String, state: &mut Editor
|
||||
}
|
||||
if escape {
|
||||
state.mention = None;
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
if accept {
|
||||
let label = filtered[query.selected.min(n - 1)].label.clone();
|
||||
insert_mention(ui.ctx(), id, prompt, state, &label);
|
||||
if !accept {
|
||||
return None;
|
||||
}
|
||||
let item = filtered[query.selected.min(n - 1)];
|
||||
insert_mention(ui.ctx(), id, prompt, state, &item.label);
|
||||
Some((item.label.clone(), item.token.clone()))
|
||||
}
|
||||
|
||||
/// Opens, updates or closes the dropdown from the text before the cursor.
|
||||
@@ -284,7 +294,7 @@ fn insert_mention(ctx: &egui::Context, id: Id, prompt: &mut String, state: &mut
|
||||
}
|
||||
}
|
||||
|
||||
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>) {
|
||||
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>) -> Option<(String, Option<String>)> {
|
||||
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;
|
||||
@@ -317,23 +327,25 @@ fn mention_dropdown(ui: &Ui, id: Id, field: Rect, prompt: &mut String, state: &m
|
||||
MentionKind::Image => Icon::Image,
|
||||
MentionKind::Video => Icon::Video,
|
||||
MentionKind::Audio => Icon::Music,
|
||||
MentionKind::Character => Icon::User,
|
||||
};
|
||||
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());
|
||||
picked = Some((item.label.clone(), item.token.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);
|
||||
if let Some((label, _)) = &picked {
|
||||
insert_mention(ui.ctx(), id, prompt, state, label);
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// The three-line grip that resizes the field (`.promptbox-resize-wrap::after`).
|
||||
@@ -382,14 +394,20 @@ fn highlight(text: &str, mentions: &[MentionItem]) -> LayoutJob {
|
||||
job
|
||||
}
|
||||
|
||||
/// `@Name` mentions for characters (newest first), coloured by position.
|
||||
pub fn character_mentions<'a>(characters: impl Iterator<Item = (&'a str, &'a str, Option<String>)>) -> Vec<MentionItem> {
|
||||
characters.enumerate().map(|(i, (name, token, avatar))| MentionItem { label: format!("@{name}"), kind: MentionKind::Character, index: i + 1, preview: avatar, token: Some(token.to_owned()) }).collect()
|
||||
}
|
||||
|
||||
/// 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",
|
||||
MentionKind::Character => "Character",
|
||||
};
|
||||
previews.enumerate().map(|(i, preview)| MentionItem { label: format!("@{name}{}", i + 1), kind, index: i + 1, preview }).collect()
|
||||
previews.enumerate().map(|(i, preview)| MentionItem { label: format!("@{name}{}", i + 1), kind, index: i + 1, preview, token: None }).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,6 +68,8 @@ pub enum PromptBoxAction {
|
||||
ClearAll,
|
||||
DroppedFiles(Vec<PathBuf>),
|
||||
PastedImage(Vec<u8>),
|
||||
/// A mention picked from the dropdown: (label, character token).
|
||||
MentionPicked(String, Option<String>),
|
||||
}
|
||||
|
||||
/// UI state that outlives a frame.
|
||||
@@ -99,6 +101,9 @@ pub fn show(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut Pro
|
||||
if out.submit {
|
||||
actions.push(PromptBoxAction::Generate);
|
||||
}
|
||||
if let Some((label, token)) = out.picked {
|
||||
actions.push(PromptBoxAction::MentionPicked(label, token));
|
||||
}
|
||||
if out.open_fullscreen {
|
||||
state.fullscreen = true;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ fn typing_at_offers_mentions_and_enter_inserts_one() {
|
||||
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");
|
||||
assert_eq!(harness.state().actions, [r#"MentionPicked("@Image2", None)"#], "Enter picked a mention, it didn't generate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -133,6 +133,7 @@ pub enum ShellAction {
|
||||
SignOut,
|
||||
OpenSettings,
|
||||
SetViewMode(ViewMode),
|
||||
SetAutoplay(bool),
|
||||
ToggleSelect,
|
||||
}
|
||||
|
||||
@@ -286,8 +287,17 @@ fn account_row(ui: &mut Ui, name: &str, credits: Option<u64>) -> egui::Response
|
||||
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> {
|
||||
/// The feed toggles a page's header shows.
|
||||
pub struct FeedToggles {
|
||||
pub mode: ViewMode,
|
||||
pub selecting: bool,
|
||||
/// Shown on pages with videos (`GalleryAutoplayToggle`).
|
||||
pub autoplay: Option<bool>,
|
||||
}
|
||||
|
||||
/// The content panel's header: breadcrumbs, and the select, previews and grid/list toggles on
|
||||
/// feed pages.
|
||||
pub fn page_header(ui: &mut Ui, page: Page, feed_toggles: Option<FeedToggles>) -> Option<ShellAction> {
|
||||
let mut action = None;
|
||||
let rect = ui.max_rect();
|
||||
ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, theme::hairline());
|
||||
@@ -302,12 +312,18 @@ pub fn page_header(ui: &mut Ui, page: Page, feed_toggles: Option<(ViewMode, bool
|
||||
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 {
|
||||
if let Some(t) = feed_toggles {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(m) = view_toggle(ui, mode) {
|
||||
if let Some(m) = view_toggle(ui, t.mode) {
|
||||
action = Some(ShellAction::SetViewMode(m));
|
||||
}
|
||||
if segmented(ui, &[(Icon::SquareCheck, if selecting { "Exit selection" } else { "Select items" }, selecting)]).is_some() {
|
||||
if let Some(playing) = t.autoplay {
|
||||
// Right-to-left: stills first so "play" ends up on the left.
|
||||
if let Some(i) = segmented(ui, &[(Icon::Image, "Still thumbnails", !playing), (Icon::Play, "Play video previews", playing)]) {
|
||||
action = Some(ShellAction::SetAutoplay(i == 1));
|
||||
}
|
||||
}
|
||||
if segmented(ui, &[(Icon::SquareCheck, if t.selecting { "Exit selection" } else { "Select items" }, t.selecting)]).is_some() {
|
||||
action = Some(ShellAction::ToggleSelect);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ pub enum Icon {
|
||||
SquareCheck,
|
||||
Trash,
|
||||
Upload,
|
||||
User,
|
||||
Video,
|
||||
Volume,
|
||||
VolumeOff,
|
||||
@@ -299,6 +300,10 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
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::User => {
|
||||
g.circle(p, 12.0, 8.0, 4.5);
|
||||
g.arc(p, 12.0, 22.0, 8.0, 180.0, 360.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)]);
|
||||
|
||||
Reference in New Issue
Block a user