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:
bflatastic
2026-10-11 01:14:04 +07:00
parent bb27304b8b
commit 26db6ea3fa
26 changed files with 1823 additions and 101 deletions
Generated
+324 -7
View File
@@ -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",
+1
View File
@@ -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"] }
+4 -3
View File
@@ -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:
+94 -8
View File
@@ -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);
}
+102
View File
@@ -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(())
}
}
+132 -41
View File
@@ -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())
}
+108 -2
View File
@@ -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);
}
+422
View File
@@ -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}");
}
}
+41
View File
@@ -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)]
+10 -2
View File
@@ -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));
+4 -1
View File
@@ -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),
}
}
+2
View File
@@ -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.
+3
View File
@@ -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);
+337
View File
@@ -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);
}
}
+25 -2
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod account;
pub mod characters;
pub mod library_picker;
pub mod lightbox;
+30 -5
View File
@@ -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));
+101 -6
View File
@@ -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());
}
}
+1 -1
View File
@@ -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 {
+15 -2
View File
@@ -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));
+33 -15
View File
@@ -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)]
+5
View File
@@ -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;
}
+1 -1
View File
@@ -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]
+21 -5
View File
@@ -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);
}
});
+5
View File
@@ -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)]);