mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 07:45:55 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b88979859 | ||
|
|
20f16236e6 | ||
|
|
df3dac8982 | ||
|
|
ccea741443 |
@@ -36,9 +36,7 @@ pub enum CommonAspectRatio {
|
||||
|
||||
// Auto values that bake in resolution
|
||||
// These are from the Seedream models
|
||||
#[serde(rename = "auto_2k")]
|
||||
Auto2k,
|
||||
#[serde(rename = "auto_4k")]
|
||||
Auto4k,
|
||||
|
||||
// Defined aspect ratios that bake in resolution
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
|
||||
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::plan_generate_image_fal_nano_banana_2;
|
||||
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub struct GenerateImageRequest<'a> {
|
||||
/// Which model to use.
|
||||
pub model: CommonImageModel,
|
||||
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
/// The result of estimating the cost of an image generation plan.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageGenerationCostEstimate {
|
||||
pub cost_in_credits: Option<u64>,
|
||||
pub cost_in_usd_cents: Option<u64>,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use anyhow::bail;
|
||||
use log::{error, info};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
pub struct FfmpegTranscodeToMp4Args<'a> {
|
||||
pub video_input_path: &'a Path,
|
||||
pub video_output_path: &'a Path,
|
||||
}
|
||||
|
||||
/// Transcode an arbitrary input video into a streaming-friendly H.264 / AAC
|
||||
/// MP4 file.
|
||||
///
|
||||
/// Output container: MP4 (faststart-enabled so the moov atom lives at the
|
||||
/// front, which makes the file usable by HTTP range-request video players
|
||||
/// before the whole thing has downloaded).
|
||||
///
|
||||
/// Output streams:
|
||||
/// - video: H.264 (libx264), yuv420p, CRF 23, "fast" preset
|
||||
/// - audio: AAC (libfdk-aac unavailable in stock ffmpeg → libmp3lame? no →
|
||||
/// plain `aac`), 128 kbps
|
||||
///
|
||||
/// Audio is transcoded rather than copied so the output is consistent
|
||||
/// regardless of the input container's audio codec.
|
||||
pub fn ffmpeg_transcode_to_mp4(args: FfmpegTranscodeToMp4Args) -> AnyhowResult<()> {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
|
||||
command
|
||||
.arg("-nostdin")
|
||||
.arg("-y")
|
||||
.arg("-i").arg(args.video_input_path)
|
||||
// Video: H.264, broadly compatible chroma subsampling.
|
||||
.arg("-c:v").arg("libx264")
|
||||
.arg("-preset").arg("fast")
|
||||
.arg("-crf").arg("23")
|
||||
.arg("-pix_fmt").arg("yuv420p")
|
||||
// Audio: AAC at 128 kbps.
|
||||
.arg("-c:a").arg("aac")
|
||||
.arg("-b:a").arg("128k")
|
||||
// MP4 container with the moov atom moved to the front for fast HTTP streaming.
|
||||
.arg("-movflags").arg("+faststart")
|
||||
.arg(args.video_output_path);
|
||||
|
||||
info!("Calling ffmpeg (transcode to mp4)...");
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
error!("bad exit status: {}", output.status);
|
||||
|
||||
let _r = std::io::stdout().write_all(&output.stdout);
|
||||
let _r = std::io::stderr().write_all(&output.stderr);
|
||||
|
||||
bail!("ffmpeg transcode to mp4 failed: {:?}", output.status.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempdir::TempDir;
|
||||
use test_utils::test_file_path::test_file_path;
|
||||
|
||||
#[test]
|
||||
fn test_transcode_mov_to_mp4() {
|
||||
let input_path = test_file_path("test_data/video/mov/majoras_mask_intro.mov")
|
||||
.expect("test video should exist");
|
||||
|
||||
let temp_dir = TempDir::new_in("/tmp", "ffmpeg_transcode_to_mp4_test")
|
||||
.expect("should create temp dir");
|
||||
|
||||
let output_path = temp_dir.path().join("transcoded.mp4");
|
||||
|
||||
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
|
||||
video_input_path: &input_path,
|
||||
video_output_path: &output_path,
|
||||
}).expect("ffmpeg should succeed");
|
||||
|
||||
// ── Output file exists and is non-empty ───────────────────────────────
|
||||
assert!(output_path.exists(), "output mp4 should exist");
|
||||
let metadata = std::fs::metadata(&output_path).expect("should stat output");
|
||||
assert!(metadata.len() > 0, "output mp4 should be non-empty");
|
||||
|
||||
// ── ffprobe the output and assert codecs / container ──────────────────
|
||||
let probe = ffprobe::ffprobe(&output_path)
|
||||
.expect("ffprobe should succeed on the transcoded file");
|
||||
|
||||
// Container is mp4 — ffprobe reports it as either "mov,mp4,m4a,3gp,3g2,mj2"
|
||||
// or "mp4" depending on version. Both contain "mp4".
|
||||
let format_name = &probe.format.format_name;
|
||||
assert!(
|
||||
format_name.contains("mp4"),
|
||||
"expected mp4 container, got format_name = {:?}",
|
||||
format_name,
|
||||
);
|
||||
|
||||
// Video stream is H.264.
|
||||
let video_stream = probe.streams.iter()
|
||||
.find(|s| s.codec_type.as_deref() == Some("video"))
|
||||
.expect("should have a video stream");
|
||||
assert_eq!(
|
||||
video_stream.codec_name.as_deref(),
|
||||
Some("h264"),
|
||||
"expected H.264 video, got {:?}",
|
||||
video_stream.codec_name,
|
||||
);
|
||||
assert_eq!(
|
||||
video_stream.pix_fmt.as_deref(),
|
||||
Some("yuv420p"),
|
||||
"expected yuv420p pixel format, got {:?}",
|
||||
video_stream.pix_fmt,
|
||||
);
|
||||
|
||||
// Audio stream is AAC (if the source has audio — Majora's Mask intro does).
|
||||
let audio_stream = probe.streams.iter()
|
||||
.find(|s| s.codec_type.as_deref() == Some("audio"))
|
||||
.expect("should have an audio stream");
|
||||
assert_eq!(
|
||||
audio_stream.codec_name.as_deref(),
|
||||
Some("aac"),
|
||||
"expected AAC audio, got {:?}",
|
||||
audio_stream.codec_name,
|
||||
);
|
||||
|
||||
// Sanity-check that the video has non-zero dimensions / duration.
|
||||
assert!(video_stream.width.unwrap_or(0) > 0, "video should have width > 0");
|
||||
assert!(video_stream.height.unwrap_or(0) > 0, "video should have height > 0");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ffmpeg_timestamp_from_duration;
|
||||
pub mod ffmpeg_transcode_to_mp4;
|
||||
pub mod ffmpeg_trim_and_resample;
|
||||
pub mod ffmpeg_video_first_frame_to_jpg_thumbnail;
|
||||
pub mod ffmpeg_video_gif_preview;
|
||||
|
||||
+122
-7
@@ -17,6 +17,7 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
|
||||
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
|
||||
use filesys::file_read_bytes::file_read_bytes;
|
||||
@@ -114,6 +115,13 @@ static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
])
|
||||
});
|
||||
|
||||
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
HashSet::from([
|
||||
// Video
|
||||
"video/quicktime", // .qt quicktime files
|
||||
])
|
||||
});
|
||||
|
||||
/// This endpoint is for uploading video files.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -238,7 +246,9 @@ pub async fn upload_new_video_media_file_handler(
|
||||
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
|
||||
})?;
|
||||
|
||||
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
|
||||
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
|
||||
|
||||
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
|
||||
// NB: Don't let our error message inject malicious strings
|
||||
let filtered_mimetype = mimetype
|
||||
.chars()
|
||||
@@ -248,16 +258,74 @@ pub async fn upload_new_video_media_file_handler(
|
||||
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
|
||||
}
|
||||
|
||||
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
|
||||
// Hold on to the user's original file (path + mimetype + extension) so we
|
||||
// can upload it as a `_original.{ext}` sibling later if we end up
|
||||
// transcoding and/or resampling it.
|
||||
|
||||
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
|
||||
let original_mimetype: String = mimetype.clone();
|
||||
let original_filename_extension: Option<String> = form.file.file_name.as_deref()
|
||||
.and_then(|filename| std::path::Path::new(filename).extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_string());
|
||||
let original_extension: String = mimetype_to_extension(&original_mimetype)
|
||||
.map(|ext| ext.to_string())
|
||||
.or(original_filename_extension)
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine file extension for original mimetype: {}", &original_mimetype);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
|
||||
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
|
||||
// distribution format we want for the media file record. Transcode them to
|
||||
// mp4 in a tempdir so we can resample / upload mp4 instead.
|
||||
|
||||
// Tempdirs we need to keep alive across the upload step. They get dropped at
|
||||
// function exit which cleans up the on-disk files.
|
||||
let mut transcode_tempdir_ref = None;
|
||||
let mut resample_tempdir_ref = None;
|
||||
|
||||
let mut final_upload_file_path = form.file.file.path().to_path_buf();
|
||||
|
||||
if needs_transcode {
|
||||
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
|
||||
.map_err(|err| {
|
||||
error!("Problem creating transcode temp dir: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
|
||||
|
||||
info!("Transcoding {} → mp4", &original_mimetype);
|
||||
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
|
||||
video_input_path: form.file.file.path(),
|
||||
video_output_path: &transcoded_path,
|
||||
}).map_err(|err| {
|
||||
error!("Problem transcoding video to mp4: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// The canonical file is now the transcoded mp4. Read its bytes and pin
|
||||
// its mimetype to mp4 so all the downstream metadata reflects the
|
||||
// transcoded file rather than the user's original upload.
|
||||
file_bytes = file_read_bytes(&transcoded_path)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading transcoded mp4: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
mimetype = "video/mp4".to_string();
|
||||
final_upload_file_path = transcoded_path;
|
||||
transcode_tempdir_ref = Some(transcode_tempdir);
|
||||
}
|
||||
|
||||
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
|
||||
|
||||
let should_resample = form.maybe_resample_fps.is_some()
|
||||
|| form.maybe_trim_start_millis.is_some()
|
||||
|| form.maybe_trim_end_millis.is_some();
|
||||
|
||||
let mut save_tempdir_ref = None; // NB: Save from Drop
|
||||
|
||||
let mut final_upload_file_path = form.file.file.path().to_path_buf();
|
||||
|
||||
if should_resample {
|
||||
// TODO(bt,2024-09-11): Should include entropy so concurrent requests don't overwrite
|
||||
let frame_temp_dir = server_state.temp_dir_creator.new_tempdir("ffmpeg")
|
||||
@@ -272,8 +340,10 @@ pub async fn upload_new_video_media_file_handler(
|
||||
let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0));
|
||||
let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0));
|
||||
|
||||
// NB: Resample reads from `final_upload_file_path` (which is the transcoded
|
||||
// mp4 if we transcoded above, or the user's original upload otherwise).
|
||||
ffmpeg_trim_and_resample(Args {
|
||||
video_input_path: form.file.file.path(),
|
||||
video_input_path: &final_upload_file_path,
|
||||
video_output_path: &video_output_path,
|
||||
maybe_new_frame_rate,
|
||||
maybe_start_offset,
|
||||
@@ -297,9 +367,13 @@ pub async fn upload_new_video_media_file_handler(
|
||||
})?;
|
||||
|
||||
final_upload_file_path = video_output_path;
|
||||
save_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
|
||||
resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
|
||||
}
|
||||
|
||||
// True if we touched the user's file in any way (transcoded, resampled, or both).
|
||||
// We'll upload their unmodified original alongside the canonical file when so.
|
||||
let should_upload_original = needs_transcode || should_resample;
|
||||
|
||||
// ==================== OTHER FILE METADATA ==================== //
|
||||
|
||||
let mut maybe_duration_millis = None;
|
||||
@@ -358,6 +432,47 @@ pub async fn upload_new_video_media_file_handler(
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
|
||||
// If we transcoded and/or resampled the user's upload, also upload the
|
||||
// unmodified original alongside the canonical file as `_original.{ext}`
|
||||
// (sharing the same object hash / directory). The media file record points
|
||||
// at the canonical file, not this sibling.
|
||||
|
||||
if should_upload_original {
|
||||
// Build a sibling path that lives in the same directory as the canonical
|
||||
// file (same hash) but with `_original.{ext}` baked into the suffix.
|
||||
let original_suffix = format!("_original.{}", original_extension);
|
||||
let original_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
public_upload_path.get_object_hash(),
|
||||
PREFIX,
|
||||
Some(&original_suffix),
|
||||
);
|
||||
|
||||
info!(
|
||||
"Uploading original sibling to bucket path: {}",
|
||||
original_bucket_path.get_full_object_path_str(),
|
||||
);
|
||||
|
||||
let original_bytes = file_read_bytes(&original_file_path)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading original file for sibling upload: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type(
|
||||
original_bucket_path.get_full_object_path_str(),
|
||||
original_bytes.as_ref(),
|
||||
&original_mimetype,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// NB: Sibling failure is non-fatal — the canonical upload already
|
||||
// succeeded and the user can still use the media. Log loudly.
|
||||
warn!("Upload original sibling to bucket error: {:?}", e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
let maybe_scene_source_media_file_token = form.maybe_scene_source_media_file_token
|
||||
.as_ref()
|
||||
.map(|token| &token.0);
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ use artcraft_router::api::provider::Provider;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::transform_request::hydrate_to_router_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of an image generation.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::transform_request::hydrate_to_router_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of a video generation.
|
||||
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
//! Distillation step for the omni-gen image endpoints.
|
||||
//!
|
||||
//! Takes a raw [`OmniGenImageCostAndGenerateRequest`] plus a (pre-computed)
|
||||
//! `MediaFileToken -> Url` map and produces a fully self-contained
|
||||
//! [`DistilledImageRequest`] holding:
|
||||
//! - the (private) router request, kept for inspection in tests / debugging
|
||||
//! - the [`ImageGenerationCostEstimate`] (Artcraft provider, what we bill on)
|
||||
//! - the [`ImageGenerationPlan`] (Fal provider, what we actually execute)
|
||||
//!
|
||||
//! All borrows inside `request` and `plan` resolve to heap data owned by the
|
||||
//! returned struct, so it has no caller-managed lifetimes and can be moved /
|
||||
//! returned freely. Tests can construct one in isolation and assert against
|
||||
//! its `cost` / `plan` directly.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::warn;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
|
||||
use artcraft_router::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::hydrate_to_router_request::hydrate_to_router_request;
|
||||
|
||||
/// Self-contained, owned representation of a fully-distilled omni-gen image
|
||||
/// request: the router request, the bill-on cost estimate, and the executable
|
||||
/// plan, all in one place.
|
||||
///
|
||||
/// Borrows inside `request` and `plan` point into the boxed owned data fields
|
||||
/// below; those boxes live for the lifetime of `Self`, so the borrows are
|
||||
/// always valid. Construct exclusively via [`distill_image_request`].
|
||||
pub struct DistilledImageRequest {
|
||||
// Owned heap-stable backing data for the borrows in `request` / `plan`.
|
||||
// These boxes are never moved out and outlive both `request` and `plan` by
|
||||
// virtue of being dropped together with them when `Self` is dropped.
|
||||
// SAFETY backing for the lifetime erasure performed in `distill_image_request`.
|
||||
_owned_image_input_urls: Option<Box<Vec<String>>>,
|
||||
_owned_prompt: Option<Box<String>>,
|
||||
_owned_idempotency_token: Option<Box<String>>,
|
||||
|
||||
/// The fully-built router request, with `provider = Fal` so it matches the
|
||||
/// plan we actually execute. Private — useful for tests / debugging only.
|
||||
///
|
||||
/// Borrows inside this request point into the `_owned_*` boxes above. Access
|
||||
/// only via [`DistilledImageRequest::request`] (an accessor that ties the
|
||||
/// returned reference to `&self`) — never moved out, otherwise the borrows
|
||||
/// would dangle once the boxes are dropped with `Self`.
|
||||
request: GenerateImageRequest<'static>,
|
||||
|
||||
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
|
||||
pub cost: ImageGenerationCostEstimate,
|
||||
|
||||
/// Execution plan as computed by the Fal provider — what we hand to the router.
|
||||
///
|
||||
/// Borrows inside this plan point into the `_owned_*` boxes above (and into
|
||||
/// `request`, which itself points into them). Access only via
|
||||
/// [`DistilledImageRequest::plan`] — moving the plan out of `Self` would
|
||||
/// dangle the borrows once the boxes are dropped.
|
||||
plan: ImageGenerationPlan<'static>,
|
||||
}
|
||||
|
||||
impl DistilledImageRequest {
|
||||
/// Borrow the execution plan. The returned reference's lifetime is tied to
|
||||
/// `&self`, so the plan can never outlive the boxes that back its borrows.
|
||||
pub fn plan(&self) -> &ImageGenerationPlan<'_> {
|
||||
// SAFETY: the plan was constructed with `'static` borrows that actually
|
||||
// point into `self._owned_*`. Re-binding the lifetime to `&self` exposes
|
||||
// the true invariant to callers.
|
||||
&self.plan
|
||||
}
|
||||
|
||||
/// Borrow the underlying router request. Useful for tests / debugging.
|
||||
/// (Same lifetime invariant as [`Self::plan`].)
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn request(&self) -> &GenerateImageRequest<'_> {
|
||||
&self.request
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`DistilledImageRequest`] from a raw API request and a pre-computed
|
||||
/// `MediaFileToken -> Url` hydration map.
|
||||
///
|
||||
/// The hydration map should already have been resolved by the caller (via
|
||||
/// `lookup_image_urls_as_map` or equivalent) — distillation does no I/O.
|
||||
///
|
||||
/// Returns `BadInputWithSimpleMessage` if any image media token referenced by
|
||||
/// the request is missing from the hydration map.
|
||||
pub fn distill_image_request(
|
||||
request: &OmniGenImageCostAndGenerateRequest,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<DistilledImageRequest, AdvancedCommonWebError> {
|
||||
// 1. Convert the raw API request into a router request (still borrowing
|
||||
// from `request`).
|
||||
let initial = hydrate_to_router_request(request)?;
|
||||
|
||||
// 2. Hoist every borrowed field into an owned `Box` so the resulting
|
||||
// DistilledImageRequest doesn't borrow from the caller's `request`.
|
||||
let owned_prompt: Option<Box<String>> = initial.prompt.map(|s| Box::new(s.to_string()));
|
||||
let owned_idempotency_token: Option<Box<String>> =
|
||||
initial.idempotency_token.map(|s| Box::new(s.to_string()));
|
||||
|
||||
let owned_image_input_urls: Option<Box<Vec<String>>> = build_owned_image_input_urls(
|
||||
request.image_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
|
||||
// 3. Build a `GenerateImageRequest<'static>` whose borrows point into the
|
||||
// owned boxes from step 2.
|
||||
//
|
||||
// SAFETY: each box lives in `Self` for the entire lifetime of the returned
|
||||
// DistilledImageRequest, and the 'static-typed borrows we synthesize here
|
||||
// are never exposed outside of `Self`. The plan / request fields can only
|
||||
// be accessed through `&self`, so the borrows can never outlive the boxes.
|
||||
let prompt_static: Option<&'static str> = owned_prompt.as_deref().map(|s: &String| {
|
||||
let raw: *const str = s.as_str();
|
||||
unsafe { &*raw }
|
||||
});
|
||||
let idempotency_static: Option<&'static str> = owned_idempotency_token
|
||||
.as_deref()
|
||||
.map(|s: &String| {
|
||||
let raw: *const str = s.as_str();
|
||||
unsafe { &*raw }
|
||||
});
|
||||
let image_inputs_static: Option<ImageListRef<'static>> =
|
||||
owned_image_input_urls.as_deref().map(|v: &Vec<String>| {
|
||||
let raw: *const Vec<String> = v;
|
||||
ImageListRef::Urls(unsafe { &*raw })
|
||||
});
|
||||
|
||||
// The "canonical" request used for both billing and execution.
|
||||
// Provider here is Fal (what we'll actually execute against). The cost path
|
||||
// below builds a sibling request with provider = Artcraft.
|
||||
let request_static: GenerateImageRequest<'static> = GenerateImageRequest {
|
||||
model: initial.model,
|
||||
provider: Provider::Fal,
|
||||
prompt: prompt_static,
|
||||
image_inputs: image_inputs_static,
|
||||
resolution: initial.resolution,
|
||||
aspect_ratio: initial.aspect_ratio,
|
||||
image_batch_count: initial.image_batch_count,
|
||||
horizontal_angle: initial.horizontal_angle,
|
||||
vertical_angle: initial.vertical_angle,
|
||||
zoom: initial.zoom,
|
||||
request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy,
|
||||
generation_mode_mismatch_strategy: initial.generation_mode_mismatch_strategy,
|
||||
idempotency_token: idempotency_static,
|
||||
};
|
||||
|
||||
// 4. Cost estimate. Always use the Artcraft provider for billing regardless
|
||||
// of which provider executes the request — that's what the user is paying for.
|
||||
let cost_request = GenerateImageRequest {
|
||||
provider: Provider::Artcraft,
|
||||
..request_static.clone()
|
||||
};
|
||||
let cost_plan = cost_request.build().map_err(|e| {
|
||||
warn!("Failed to build cost plan during distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
let cost: ImageGenerationCostEstimate = cost_plan.estimate_costs();
|
||||
// The cost plan borrows into `cost_request` which is about to be dropped;
|
||||
// `cost` is owned data so this is fine.
|
||||
drop(cost_plan);
|
||||
drop(cost_request);
|
||||
|
||||
// 5. Execution plan. Built from the Fal-flavored canonical request. The plan
|
||||
// borrows from the request, which in turn borrows from the owned boxes —
|
||||
// so the plan's borrows resolve to heap-stable data inside `Self`.
|
||||
//
|
||||
// SAFETY: the borrow we hand to `build()` is `&'static`, justified by the
|
||||
// same invariants as above (the underlying data lives for the lifetime of
|
||||
// the returned DistilledImageRequest).
|
||||
let plan: ImageGenerationPlan<'static> = {
|
||||
let request_ref: &'static GenerateImageRequest<'static> = unsafe {
|
||||
let raw: *const GenerateImageRequest<'static> = &request_static;
|
||||
&*raw
|
||||
};
|
||||
request_ref.build().map_err(|e| {
|
||||
warn!("Failed to build image generation plan during distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(DistilledImageRequest {
|
||||
_owned_image_input_urls: owned_image_input_urls,
|
||||
_owned_prompt: owned_prompt,
|
||||
_owned_idempotency_token: owned_idempotency_token,
|
||||
request: request_static,
|
||||
cost,
|
||||
plan,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the owned `Vec<String>` of image input URLs from the raw token list +
|
||||
/// hydration map. Errors out if any token referenced by the request is missing
|
||||
/// from the hydration map.
|
||||
fn build_owned_image_input_urls(
|
||||
image_media_tokens: Option<&Vec<MediaFileToken>>,
|
||||
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<Option<Box<Vec<String>>>, AdvancedCommonWebError> {
|
||||
let tokens = match image_media_tokens {
|
||||
Some(tokens) if !tokens.is_empty() => tokens,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let map = hydration_map.ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"image_media_tokens supplied but no hydration map was provided".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut urls: Vec<String> = Vec::with_capacity(tokens.len());
|
||||
for token in tokens {
|
||||
match map.get(token) {
|
||||
Some(url) => urls.push(url.to_string()),
|
||||
None => {
|
||||
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
|
||||
"Image media token not found in hydration map: {:?}",
|
||||
token
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(Box::new(urls)))
|
||||
}
|
||||
+1
-5
@@ -1,9 +1,5 @@
|
||||
pub mod omni_gen_image_generate_handler;
|
||||
pub(crate) mod distill_image_request;
|
||||
mod request_to_costs;
|
||||
mod request_to_plan;
|
||||
mod resolve_media_tokens;
|
||||
pub(crate) mod hydrate_to_router_request;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod transform_request;
|
||||
|
||||
+31
-35
@@ -1,11 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use sqlx::Acquire;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
|
||||
@@ -27,16 +25,17 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
|
||||
|
||||
use super::distill_image_request::distill_image_request;
|
||||
use super::request_to_costs::request_to_costs;
|
||||
use super::request_to_plan::request_to_plan;
|
||||
use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media};
|
||||
use super::transform_request::hydrate_to_router_request;
|
||||
|
||||
/// Generate an image using the omni-gen unified endpoint.
|
||||
#[utoipa::path(
|
||||
@@ -60,10 +59,22 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
|
||||
|
||||
// ==================== TRANSFORM REQUEST + PLAN ==================== //
|
||||
|
||||
let mut generate_request = hydrate_to_router_request(&request)?;
|
||||
|
||||
let maybe_prompt_model_type: Option<CommonModelType> = request.model
|
||||
.as_ref()
|
||||
.map(|m| m.to_common_model_type());
|
||||
|
||||
// ==================== COST ==================== //
|
||||
|
||||
info!("\n\n Generate request: {:?}\n\n", generate_request);
|
||||
|
||||
info!(">>> Building cost estimate...");
|
||||
|
||||
let cost_estimate = request_to_costs(&generate_request)?;
|
||||
|
||||
// ==================== SESSION ==================== //
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
@@ -104,42 +115,27 @@ pub async fn omni_gen_image_generate_handler(
|
||||
})?;
|
||||
|
||||
// ==================== RESOLVE MEDIA TOKENS ==================== //
|
||||
// Look up media file tokens BEFORE distilling. distill_image_request takes
|
||||
// a pre-computed `MediaFileToken -> Url` map and does no I/O of its own.
|
||||
|
||||
let media_file_hydration_map: Option<HashMap<MediaFileToken, Url>> = match request.image_media_tokens.as_ref() {
|
||||
Some(tokens) if !tokens.is_empty() => {
|
||||
info!("Resolving {} image media tokens to CDN URLs", tokens.len());
|
||||
let raw = lookup_image_urls_as_map(
|
||||
&http_request,
|
||||
&mut mysql_connection,
|
||||
server_state.server_environment,
|
||||
tokens,
|
||||
).await?;
|
||||
let parsed: HashMap<MediaFileToken, Url> = raw.into_iter()
|
||||
.filter_map(|(token, url_str)| match Url::parse(&url_str) {
|
||||
Ok(url) => Some((token, url)),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse media file URL {:?}: {:?}", url_str, err);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(parsed)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let resolved_media = resolve_media_tokens(
|
||||
&request,
|
||||
&http_request,
|
||||
&mut mysql_connection,
|
||||
server_state.server_environment,
|
||||
).await?;
|
||||
|
||||
// ==================== DISTILL ==================== //
|
||||
apply_resolved_media(&mut generate_request, &resolved_media);
|
||||
|
||||
let distilled = distill_image_request(&request, media_file_hydration_map.as_ref())?;
|
||||
info!("\n\n Updated request: {:?}\n\n", generate_request);
|
||||
|
||||
info!(">>> Distilled cost: {:?}", distilled.cost);
|
||||
info!(">>> Distilled plan: {:?}", distilled.plan());
|
||||
// ==================== PLAN ==================== //
|
||||
|
||||
let plan = request_to_plan(&mut generate_request)?;
|
||||
|
||||
info!("\n\n Plan: {:?}\n\n", plan);
|
||||
|
||||
// ==================== BILLING ==================== //
|
||||
|
||||
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
|
||||
let cost = cost_estimate.cost_in_credits.unwrap_or(0);
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
@@ -163,7 +159,7 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
|
||||
|
||||
let generation_response = distilled.plan().generate_image(&router_client)
|
||||
let generation_response = plan.generate_image(&router_client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Image generation failed: {:?}", e);
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
mod test_flux_1_dev;
|
||||
-421
@@ -1,421 +0,0 @@
|
||||
//! Tests for omni-gen image distillation against the Flux 1 Dev model.
|
||||
//!
|
||||
//! These exercise [`distill_image_request`] end-to-end without firing any
|
||||
//! provider calls — both the cost estimate (Artcraft provider, what we bill
|
||||
//! on) and the execution plan (Fal provider, what we'd send to fal.ai) are
|
||||
//! computed synchronously from the request.
|
||||
//!
|
||||
//! Flux 1 Dev is text-to-image only, so no image refs are exercised.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
|
||||
distill_image_request, DistilledImageRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a minimal Flux 1 Dev omni request with only the fields we care about
|
||||
/// in tests filled in.
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
image_batch_count: Option<u16>,
|
||||
) -> OmniGenImageCostAndGenerateRequest {
|
||||
OmniGenImageCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonImageModel::Flux1Dev),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
image_media_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
image_batch_count,
|
||||
horizontal_angle: None,
|
||||
vertical_angle: None,
|
||||
zoom: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Distill a request and panic with a descriptive message on failure.
|
||||
/// Flux 1 Dev never uses image refs, so we always pass an empty hydration map.
|
||||
fn distill(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_image_request(request, Some(&empty))
|
||||
.expect("distill_image_request should succeed for Flux 1 Dev")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Pricing comes from `estimate_image_cost_artcraft_flux_1_dev`:
|
||||
// $0.02 / image (2 credits per image; 1 credit == 1 USD cent)
|
||||
//
|
||||
// The artcraft cost path is what omni bills on, so this matches what the
|
||||
// user is actually charged.
|
||||
//
|
||||
// (Note: the Fal cost path would also be 3¢/image for flux_1_dev — but the
|
||||
// omni endpoint always uses the Artcraft path for billing.)
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
fn cost_for_batch(image_batch_count: Option<u16>) -> (Option<u64>, Option<u64>) {
|
||||
let request = make_request(Some("a happy puppy"), None, image_batch_count);
|
||||
let distilled = distill(&request);
|
||||
(
|
||||
distilled.cost.cost_in_credits,
|
||||
distilled.cost.cost_in_usd_cents,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_batch_costs_2_cents() {
|
||||
// No batch specified → defaults to 1 image → 2¢
|
||||
let (credits, cents) = cost_for_batch(None);
|
||||
assert_eq!(credits, Some(2));
|
||||
assert_eq!(cents, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_one_costs_2_cents() {
|
||||
let (credits, cents) = cost_for_batch(Some(1));
|
||||
assert_eq!(credits, Some(2));
|
||||
assert_eq!(cents, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_two_costs_4_cents() {
|
||||
let (credits, cents) = cost_for_batch(Some(2));
|
||||
assert_eq!(credits, Some(4));
|
||||
assert_eq!(cents, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_three_costs_6_cents() {
|
||||
let (credits, cents) = cost_for_batch(Some(3));
|
||||
assert_eq!(credits, Some(6));
|
||||
assert_eq!(cents, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_four_costs_8_cents() {
|
||||
let (credits, cents) = cost_for_batch(Some(4));
|
||||
assert_eq!(credits, Some(8));
|
||||
assert_eq!(cents, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_max_clamps_and_costs_8_cents() {
|
||||
// PayMoreUpgrade strategy clamps batches >4 down to the max of 4.
|
||||
let (credits, cents) = cost_for_batch(Some(7));
|
||||
assert_eq!(credits, Some(8));
|
||||
assert_eq!(cents, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let aspect_ratios = [
|
||||
None,
|
||||
Some(CommonAspectRatio::Square),
|
||||
Some(CommonAspectRatio::SquareHd),
|
||||
Some(CommonAspectRatio::WideSixteenByNine),
|
||||
Some(CommonAspectRatio::TallNineBySixteen),
|
||||
Some(CommonAspectRatio::Auto),
|
||||
];
|
||||
for ar in aspect_ratios {
|
||||
let request = make_request(Some("p"), ar, Some(2));
|
||||
let distilled = distill(&request);
|
||||
assert_eq!(
|
||||
distilled.cost.cost_in_usd_cents,
|
||||
Some(4),
|
||||
"expected 4¢ regardless of aspect ratio (got {:?})",
|
||||
ar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_prompt() {
|
||||
let with_prompt = distill(&make_request(Some("a cat"), None, Some(3)));
|
||||
let without_prompt = distill(&make_request(None, None, Some(3)));
|
||||
assert_eq!(with_prompt.cost.cost_in_usd_cents, Some(6));
|
||||
assert_eq!(without_prompt.cost.cost_in_usd_cents, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let request = make_request(Some("p"), None, Some(1));
|
||||
let distilled = distill(&request);
|
||||
assert!(!distilled.cost.is_free);
|
||||
assert!(!distilled.cost.is_unlimited);
|
||||
assert!(!distilled.cost.is_rate_limited);
|
||||
assert!(!distilled.cost.has_watermark);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// PLAN
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The distilled plan is the Fal-side `PlanFalFlux1Dev`:
|
||||
// - prompt: passthrough
|
||||
// - maybe_image_url: always None (Flux 1 Dev never gets image refs in tests)
|
||||
// - aspect_ratio: mapped from CommonAspectRatio with sensible fallbacks
|
||||
// - num_images: mapped from image_batch_count (default 1, clamped to 1..=4)
|
||||
mod plan {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
|
||||
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::{
|
||||
FalFlux1DevNumImages, PlanFalFlux1Dev,
|
||||
};
|
||||
use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::Flux1DevAspectRatio;
|
||||
|
||||
/// Distill and run an assertion against the inner FalFlux1Dev plan.
|
||||
///
|
||||
/// We pass the plan by reference (rather than moving it out of `distilled`)
|
||||
/// because the plan's borrows are tied to data owned by `distilled` —
|
||||
/// moving it out would dangle those borrows.
|
||||
fn with_fal_plan<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
|
||||
request: &OmniGenImageCostAndGenerateRequest,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill(request);
|
||||
match distilled.plan() {
|
||||
ImageGenerationPlan::FalFlux1Dev(plan) => assertion(plan),
|
||||
other => panic!("expected ImageGenerationPlan::FalFlux1Dev, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_plan_for_aspect_ratio<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
|
||||
ar: Option<CommonAspectRatio>,
|
||||
assertion: F,
|
||||
) {
|
||||
with_fal_plan(&make_request(Some("p"), ar, Some(1)), assertion);
|
||||
}
|
||||
|
||||
fn assert_plan_for_batch<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
|
||||
batch: Option<u16>,
|
||||
assertion: F,
|
||||
) {
|
||||
with_fal_plan(&make_request(Some("p"), None, batch), assertion);
|
||||
}
|
||||
|
||||
// ── Aspect ratio mapping ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_square() {
|
||||
assert_plan_for_aspect_ratio(None, |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_input_yields_square() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_hd_input_yields_square_hd() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::SquareHd));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_4x3_yields_landscape_4_3() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_landscape_16_9() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_alias_yields_landscape_16_9() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_3x4_yields_portrait_3_4() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_portrait_9_16() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_alias_yields_portrait_9_16() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio fallback mapping ───────────────────────────────────────
|
||||
//
|
||||
// Aspect ratios that Flux 1 Dev doesn't natively support get mapped to the
|
||||
// closest supported ratio under the PayMoreUpgrade mismatch strategy that
|
||||
// omni-gen uses.
|
||||
|
||||
#[test]
|
||||
fn wide_5x4_falls_back_to_landscape_4_3() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_3x2_falls_back_to_landscape_4_3() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideThreeByTwo), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_21x9_falls_back_to_landscape_16_9() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideTwentyOneByNine), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_4x5_falls_back_to_portrait_3_4() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallFourByFive), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_2x3_falls_back_to_portrait_3_4() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x21_falls_back_to_portrait_9_16() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineByTwentyOne), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auto* aspect ratios ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn auto_yields_square() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_2k_yields_square() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto2k), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_4k_yields_square() {
|
||||
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto4k), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Num images mapping ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_batch_count_is_one() {
|
||||
assert_plan_for_batch(None, |plan| {
|
||||
assert!(matches!(plan.num_images, FalFlux1DevNumImages::One));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_one_yields_one() {
|
||||
assert_plan_for_batch(Some(1), |plan| {
|
||||
assert!(matches!(plan.num_images, FalFlux1DevNumImages::One));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_two_yields_two() {
|
||||
assert_plan_for_batch(Some(2), |plan| {
|
||||
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Two));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_three_yields_three() {
|
||||
assert_plan_for_batch(Some(3), |plan| {
|
||||
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Three));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_four_yields_four() {
|
||||
assert_plan_for_batch(Some(4), |plan| {
|
||||
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Four));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
// PayMoreUpgrade strategy clamps over-large batches down to the max.
|
||||
assert_plan_for_batch(Some(9), |plan| {
|
||||
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Four));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
with_fal_plan(&make_request(Some("a corgi in a hat"), None, Some(1)), |plan| {
|
||||
assert_eq!(plan.prompt, Some("a corgi in a hat"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_none() {
|
||||
with_fal_plan(&make_request(None, None, Some(1)), |plan| {
|
||||
assert_eq!(plan.prompt, None);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image refs (Flux 1 Dev never has them) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn image_url_is_always_none() {
|
||||
// Flux 1 Dev is text-to-image only — `maybe_image_url` is always None
|
||||
// because we never pass image_media_tokens.
|
||||
with_fal_plan(&make_request(Some("p"), None, Some(1)), |plan| {
|
||||
assert!(plan.maybe_image_url.is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -2,7 +2,4 @@ pub mod omni_gen_video_generate_handler;
|
||||
mod request_to_costs;
|
||||
mod request_to_plan;
|
||||
mod resolve_media_tokens;
|
||||
pub(crate) mod hydrate_to_router_request;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod transform_request;
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ use crate::state::server_state::ServerState;
|
||||
use super::request_to_costs::request_to_costs;
|
||||
use super::request_to_plan::request_to_plan;
|
||||
use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media};
|
||||
use super::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use super::transform_request::hydrate_to_router_request;
|
||||
|
||||
/// Generate a video using the omni-gen unified endpoint.
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -4,8 +4,8 @@ import Media from "../pages/media";
|
||||
import PressKit from "../pages/press-kit";
|
||||
import Navbar from "../components/navbar";
|
||||
import { ToastContainer } from "../components/toast/toast";
|
||||
import CreateImage from "../pages/create-image";
|
||||
import CreateVideo from "../pages/create-video";
|
||||
// import CreateImage from "../pages/create-image";
|
||||
// import CreateVideo from "../pages/create-video";
|
||||
import Landing2 from "../pages/landing2";
|
||||
import LandingSD2 from "../pages/landing-sd2";
|
||||
import TutorialsPage from "../pages/tutorials";
|
||||
@@ -30,8 +30,8 @@ export function App() {
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing2 />} />
|
||||
<Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} />
|
||||
{/* <Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} /> */}
|
||||
<Route path="/seedance-2" element={<LandingSD2 />} />
|
||||
<Route path="/download" element={<Download />} />
|
||||
<Route path="/media" element={<Media />} />
|
||||
|
||||
@@ -17,8 +17,8 @@ import { TaskQueue } from "./task-queue";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Image", href: "/create-image" },
|
||||
{ name: "Video", href: "/create-video" },
|
||||
// { name: "Image", href: "/create-image" },
|
||||
// { name: "Video", href: "/create-video" },
|
||||
{ name: "Tutorials", href: "/tutorials" },
|
||||
{ name: "News", href: "/news" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
|
||||
@@ -11,16 +11,15 @@ const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
// In development, route API through the Vite dev server proxy to avoid CORS,
|
||||
// unless VITE_API_HOST is explicitly set (eg. for an unsafe browser that ignores CORS).
|
||||
//if (import.meta.env.DEV && !import.meta.env.VITE_API_HOST) {
|
||||
// try {
|
||||
// const origin = window.location.origin;
|
||||
// StorytellerApiHostStore.getInstance().setApiSchemeAndHost(origin);
|
||||
// } catch (e) {
|
||||
// console.warn("Failed to set dev API host override", e);
|
||||
// }
|
||||
//}
|
||||
// In development, route API through the Vite dev server origin to avoid CORS
|
||||
if (import.meta.env.DEV) {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(origin);
|
||||
} catch (e) {
|
||||
console.warn("Failed to set dev API host override", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the referrer immediately — it can change or disappear on navigation.
|
||||
// Only set once so subsequent in-site navigations don't overwrite the original.
|
||||
|
||||
@@ -50,7 +50,9 @@ const ROUTE_TO_FILTER: Record<string, string> = {
|
||||
meshes: "meshes",
|
||||
};
|
||||
|
||||
const getFilterMediaClass = (filter: string): FilterMediaClasses[] | undefined => {
|
||||
const getFilterMediaClass = (
|
||||
filter: string,
|
||||
): FilterMediaClasses[] | undefined => {
|
||||
switch (filter) {
|
||||
case "image":
|
||||
return [FilterMediaClasses.IMAGE];
|
||||
@@ -59,7 +61,11 @@ const getFilterMediaClass = (filter: string): FilterMediaClasses[] | undefined =
|
||||
case "meshes":
|
||||
return [FilterMediaClasses.DIMENSIONAL];
|
||||
default:
|
||||
return [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO, FilterMediaClasses.DIMENSIONAL];
|
||||
return [
|
||||
FilterMediaClasses.IMAGE,
|
||||
FilterMediaClasses.VIDEO,
|
||||
FilterMediaClasses.DIMENSIONAL,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,7 +97,9 @@ const getLabel = (item: any) => {
|
||||
export default function Library() {
|
||||
const { filter: filterParam } = useParams<{ filter?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const activeFilter = filterParam ? (ROUTE_TO_FILTER[filterParam] ?? "all") : "all";
|
||||
const activeFilter = filterParam
|
||||
? (ROUTE_TO_FILTER[filterParam] ?? "all")
|
||||
: "all";
|
||||
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
||||
@@ -113,11 +121,7 @@ export default function Library() {
|
||||
const checkSession = async () => {
|
||||
const usersApi = new UsersApi();
|
||||
const response = await usersApi.GetSession();
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.loggedIn &&
|
||||
response.data.user
|
||||
) {
|
||||
if (response.success && response.data?.loggedIn && response.data.user) {
|
||||
setUsername(response.data.user.username);
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
@@ -135,8 +139,8 @@ export default function Library() {
|
||||
const thumbnail = isDimensional
|
||||
? null
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.MEDIUM,
|
||||
});
|
||||
size: THUMBNAIL_SIZES.MEDIUM,
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.token,
|
||||
@@ -169,7 +173,9 @@ export default function Library() {
|
||||
|
||||
if (response.success && response.data) {
|
||||
const newItems = response.data
|
||||
.filter((item: any) => item.media_type !== FilterMediaType.SCENE_JSON)
|
||||
.filter(
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
|
||||
)
|
||||
.map(mapApiItem);
|
||||
|
||||
if (reset) {
|
||||
@@ -250,17 +256,17 @@ export default function Library() {
|
||||
const navigatePrev =
|
||||
currentIndex > 0
|
||||
? () => {
|
||||
const prev = flatItems[currentIndex - 1];
|
||||
setLightboxItem(prev);
|
||||
}
|
||||
const prev = flatItems[currentIndex - 1];
|
||||
setLightboxItem(prev);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const navigateNext =
|
||||
currentIndex >= 0 && currentIndex < flatItems.length - 1
|
||||
? () => {
|
||||
const next = flatItems[currentIndex + 1];
|
||||
setLightboxItem(next);
|
||||
}
|
||||
const next = flatItems[currentIndex + 1];
|
||||
setLightboxItem(next);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handleItemDeleted = useCallback((id: string) => {
|
||||
@@ -341,10 +347,11 @@ export default function Library() {
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => navigate(filter.route)}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${activeFilter === filter.id
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeFilter === filter.id
|
||||
? "bg-ui-controls text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} className="text-xs" />
|
||||
{filter.label}
|
||||
@@ -386,10 +393,8 @@ export default function Library() {
|
||||
</div>
|
||||
) : allItems.length === 0 && !loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-white/40 text-sm mb-4">
|
||||
No items yet. Start generating!
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<p className="text-white/40 text-sm mb-4">No items yet.</p>
|
||||
{/* <div className="flex gap-3">
|
||||
<Link to="/create-image">
|
||||
<Button variant="primary" className="text-sm px-4 py-2">
|
||||
Create Image
|
||||
@@ -403,7 +408,7 @@ export default function Library() {
|
||||
Create Video
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -4,6 +4,40 @@ import { API_TARGETS } from "./enums/Api.js";
|
||||
//import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
|
||||
|
||||
const SESSION_STORAGE_KEY = "artcraft_signed_session";
|
||||
|
||||
/** Store a signed session JWT for use as a header fallback (mobile browsers block 3rd-party cookies). */
|
||||
export function storeSignedSession(signedSession: string) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, signedSession);
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
/** Clear stored session (on logout). */
|
||||
export function clearSignedSession() {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
/** Get the stored session token, if any. */
|
||||
export function getSignedSession(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build headers with session fallback for mobile browsers. */
|
||||
export function buildSessionHeaders(base: Record<string, string>): Record<string, string> {
|
||||
const session = getSignedSession();
|
||||
if (session) {
|
||||
return { ...base, session };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
type NonNullableObject<T extends object> = NonNullable<T>;
|
||||
|
||||
export interface ApiResponse<T, P = undefined> {
|
||||
@@ -26,9 +60,7 @@ export class ApiManager {
|
||||
}
|
||||
|
||||
protected getApiSchemeAndHost(): string {
|
||||
const host = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
console.log('getSchemaAndHost()', host);
|
||||
return host;
|
||||
return StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
}
|
||||
|
||||
public async fetch<B, T>(
|
||||
@@ -64,10 +96,10 @@ export class ApiManager {
|
||||
|
||||
const response = await fetch(endpointWithQueries, {
|
||||
method,
|
||||
headers: {
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
credentials: "include",
|
||||
body: bodyInString,
|
||||
});
|
||||
@@ -91,9 +123,9 @@ export class ApiManager {
|
||||
): Promise<T> {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
},
|
||||
}),
|
||||
credentials: "include",
|
||||
body: body,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager";
|
||||
|
||||
export class EngineApi extends ApiManager {
|
||||
public async ConvertTbxToGltf({
|
||||
@@ -57,9 +57,9 @@ export class EngineApi extends ApiManager {
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
},
|
||||
}),
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager.js";
|
||||
import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager.js";
|
||||
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
|
||||
|
||||
export class PasswordResetApi extends ApiManager {
|
||||
@@ -61,10 +61,10 @@ export class PasswordResetApi extends ApiManager {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager.js";
|
||||
import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager.js";
|
||||
import { Prompts } from "./models/Prompts.js";
|
||||
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
|
||||
|
||||
@@ -209,9 +209,9 @@ export class PromptsApi extends ApiManager {
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
},
|
||||
}),
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager.js";
|
||||
import { ApiManager, ApiResponse, buildSessionHeaders, storeSignedSession, clearSignedSession } from "./ApiManager.js";
|
||||
import { UserInfo } from "./models/Users.js";
|
||||
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
|
||||
|
||||
@@ -27,10 +27,10 @@ export class UsersApi extends ApiManager {
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
credentials: "include",
|
||||
body: bodyInString,
|
||||
});
|
||||
@@ -141,6 +141,9 @@ export class UsersApi extends ApiManager {
|
||||
method: "POST",
|
||||
body: body,
|
||||
});
|
||||
if (response.success && response.signed_session) {
|
||||
storeSignedSession(response.signed_session);
|
||||
}
|
||||
return {
|
||||
success: response.success,
|
||||
data: response.success
|
||||
@@ -157,6 +160,7 @@ export class UsersApi extends ApiManager {
|
||||
}
|
||||
|
||||
public Logout(): Promise<ApiResponse<null>> {
|
||||
clearSignedSession();
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/logout`;
|
||||
return this.post<null, { success: boolean; error_message?: string }>({
|
||||
endpoint: endpoint,
|
||||
@@ -245,6 +249,9 @@ export class UsersApi extends ApiManager {
|
||||
body: body,
|
||||
});
|
||||
|
||||
if (response.success && response.signed_session) {
|
||||
storeSignedSession(response.signed_session);
|
||||
}
|
||||
return {
|
||||
success: response.success,
|
||||
data: response.success
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Artcraft / Storyteller API backend
|
||||
// Set VITE_API_HOST=http://localhost:12345 for local dev, or leave unset for production.
|
||||
//const DEFAULT_API_HOST = (import.meta as any).env?.VITE_API_HOST || "https://api.storyteller.ai";
|
||||
|
||||
const DEFAULT_API_HOST = "http://localhost:12345";
|
||||
const DEFAULT_API_HOST = "https://api.storyteller.ai";
|
||||
|
||||
// NB(bt,2025-09-25): 'nx' is creating multiple copies of the library with name
|
||||
// mangling, so the singleton pattern fails to resolve to a single instance.
|
||||
|
||||
@@ -137,7 +137,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
tabIndex={-1}
|
||||
className={twMerge(
|
||||
"w-full group relative overflow-visible rounded-md border-[3px] transition-all focus:outline-none",
|
||||
"aspect-square",
|
||||
item.mediaClass === "video" ? "aspect-video" : "aspect-square",
|
||||
selected || bulkSelected
|
||||
? "border-primary"
|
||||
: disableTooltipAndBadge
|
||||
@@ -171,7 +171,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
alt={item.label}
|
||||
className={twMerge(
|
||||
"h-full w-full bg-black/30",
|
||||
imageFit === "contain" ? "object-contain" : "object-cover",
|
||||
item.mediaClass === "video" || imageFit === "contain" ? "object-contain" : "object-cover",
|
||||
)}
|
||||
draggable={false}
|
||||
onError={onImageError}
|
||||
@@ -187,7 +187,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={twMerge("group relative w-full", "aspect-square")}>
|
||||
<div className={twMerge("group relative w-full", item.mediaClass === "video" ? "aspect-video" : "aspect-square")}>
|
||||
{/* dropdown menu */}
|
||||
{mode !== "select" && (
|
||||
<div
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../button-icon-select"
|
||||
},
|
||||
@@ -17,6 +14,9 @@
|
||||
{
|
||||
"path": "../camera-settings-modal"
|
||||
},
|
||||
{
|
||||
"path": "../toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-utils"
|
||||
},
|
||||
@@ -26,38 +26,29 @@
|
||||
{
|
||||
"path": "../../model-list"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "../popover"
|
||||
},
|
||||
{
|
||||
"path": "../slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../common"
|
||||
},
|
||||
{
|
||||
"path": "../tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../label"
|
||||
},
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../input"
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../toaster"
|
||||
"path": "../tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../button-icon-select/tsconfig.lib.json"
|
||||
},
|
||||
@@ -58,6 +55,9 @@
|
||||
{
|
||||
"path": "../camera-settings-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../toaster/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-utils/tsconfig.lib.json"
|
||||
},
|
||||
@@ -67,38 +67,29 @@
|
||||
{
|
||||
"path": "../../model-list/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../popover/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../common/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../action-reminder-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../label/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../input/tsconfig.lib.json"
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../toaster/tsconfig.lib.json"
|
||||
"path": "../tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user