route video requets (not tested)

This commit is contained in:
Hanashi 2026-08-03 22:18:20 -04:00
parent 59260258e7
commit a41d4ed204
13 changed files with 202 additions and 146 deletions

View File

@ -0,0 +1,58 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::commands::enqueue::generate_error::{CredentialProblemReason, GenerateError};
use crate::credentials::credential::Credential;
use crate::state::data_dir::app_data_root::AppDataRoot;
/// Resolve the stored credential a generation request names via
/// `credential_id`. Every failure is a [`GenerateError::CredentialProblem`],
/// which flashes the frontend's dismissable credential-error modal.
pub fn resolve_generation_credential(
maybe_credential_id: Option<&str>,
app_data_root: &AppDataRoot,
) -> Result<Credential, GenerateError> {
let credential_id = maybe_credential_id
.filter(|id| !id.trim().is_empty())
.ok_or(GenerateError::CredentialProblem(
CredentialProblemReason::NoCredentialSupplied,
))?;
let maybe_credential = app_data_root
.credentials_dir()
.find_credential_by_id(credential_id)
.map_err(GenerateError::from)?;
maybe_credential.ok_or_else(|| {
GenerateError::CredentialProblem(CredentialProblemReason::CredentialNotFound {
credential_id: credential_id.to_string(),
})
})
}
/// Rebuild the web-session credential set from a stored cookie credential.
pub fn storyteller_creds_from_credential(
credential: &Credential,
) -> Result<StorytellerCredentialSet, GenerateError> {
let cookie = credential.cookies().ok_or_else(|| {
credential_not_usable(credential, "the account has no session cookies")
})?;
StorytellerCredentialSet::parse_multi_cookie_header(&cookie.cookie_header)
.map_err(|err| {
credential_not_usable(
credential,
&format!("the stored session cookies could not be parsed ({})", err),
)
})?
.filter(|creds| !creds.is_empty())
.ok_or_else(|| {
credential_not_usable(credential, "the stored session cookies are empty")
})
}
pub fn credential_not_usable(credential: &Credential, reason: &str) -> GenerateError {
GenerateError::CredentialProblem(CredentialProblemReason::CredentialNotUsable {
credential_id: credential.id.to_string(),
reason: reason.to_string(),
})
}

View File

@ -1,2 +1,3 @@
pub mod generation_credential;
pub mod router_image_request_to_artcraft_prompt;
pub mod router_video_request_to_artcraft_prompt;

View File

@ -1,5 +1,3 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::{info, warn};
@ -15,8 +13,9 @@ use tokens::tokens::media_files::MediaFileToken;
use crate::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model;
use crate::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model;
use crate::commands::enqueue::generate_error::{CredentialProblemReason, GenerateError};
use crate::commands::enqueue::generate_error::GenerateError;
use crate::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::commands::generate::common::generation_credential::{credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential};
use crate::commands::generate::generate_image::providers::router::handle_api_providers::handle_api_key_provider;
use crate::commands::generate::generate_image::providers::router::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution};
use crate::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
@ -37,7 +36,10 @@ pub async fn handle_credential_router(
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let credential = resolve_credential(request, app_data_root)?;
let credential = resolve_generation_credential(
request.credential_id.as_deref(),
app_data_root,
)?;
info!(
"handle_credential_router: credential={} service={}",
@ -70,29 +72,6 @@ pub async fn handle_credential_router(
}
}
/// Require and load the credential named by the request.
fn resolve_credential(
request: &TauriGenerateImageRequest,
app_data_root: &AppDataRoot,
) -> Result<Credential, GenerateError> {
let credential_id = request.credential_id.as_deref()
.filter(|id| !id.trim().is_empty())
.ok_or(GenerateError::CredentialProblem(
CredentialProblemReason::NoCredentialSupplied,
))?;
let maybe_credential = app_data_root
.credentials_dir()
.find_credential_by_id(credential_id)
.map_err(GenerateError::from)?;
maybe_credential.ok_or_else(|| {
GenerateError::CredentialProblem(CredentialProblemReason::CredentialNotFound {
credential_id: credential_id.to_string(),
})
})
}
/// Generate via the router's Artcraft provider, authenticating with the
/// stored session cookies. Raw canvas/scene/mask bytes are uploaded to
/// ArtCraft first (with the same credential) so the router request only
@ -186,27 +165,6 @@ async fn handle_artcraft_credential(
})
}
/// Rebuild the web-session credential set from the stored cookie header.
fn storyteller_creds_from_credential(
credential: &Credential,
) -> Result<StorytellerCredentialSet, GenerateError> {
let cookie = credential.cookies().ok_or_else(|| {
credential_not_usable(credential, "the account has no session cookies")
})?;
StorytellerCredentialSet::parse_multi_cookie_header(&cookie.cookie_header)
.map_err(|err| {
credential_not_usable(
credential,
&format!("the stored session cookies could not be parsed ({})", err),
)
})?
.filter(|creds| !creds.is_empty())
.ok_or_else(|| {
credential_not_usable(credential, "the stored session cookies are empty")
})
}
/// Reference images for the router request: canvas first, then scene, then
/// the un-semantic reference images. (Artcraft accepts media tokens
/// directly; no URL resolution needed.)
@ -235,9 +193,3 @@ fn collect_image_inputs(
}
}
fn credential_not_usable(credential: &Credential, reason: &str) -> GenerateError {
GenerateError::CredentialProblem(CredentialProblemReason::CredentialNotUsable {
credential_id: credential.id.to_string(),
reason: reason.to_string(),
})
}

View File

@ -26,5 +26,5 @@ pub async fn handle_video_artcraft(
let router_model = tauri_video_model_to_router_model(model);
let generation_model = tauri_video_model_to_generation_model(model);
handle_artcraft_video_via_router(request, app_env_configs, &creds, router_model, generation_model).await
handle_artcraft_video_via_router(request, &app_env_configs.storyteller_host, &creds, router_model, generation_model).await
}

View File

@ -2,8 +2,8 @@ use crate::commands::enqueue::generate_error::GenerateError;
use crate::commands::generate::generate_video::request::TauriGenerateVideoRequest;
use crate::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::events::generation_events::common::GenerationModel;
use crate::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use router::api::audio_list_ref::AudioListRef;
use router::api::character_list_ref::CharacterListRef;
use router::api::router_video_model::RouterVideoModel;
@ -21,15 +21,15 @@ use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::{error, info};
pub(super) async fn handle_artcraft_video_via_router(
pub async fn handle_artcraft_video_via_router(
request: &TauriGenerateVideoRequest,
app_env_configs: &AppEnvConfigs,
api_host: &ApiHost,
creds: &StorytellerCredentialSet,
model: RouterVideoModel,
generation_model: GenerationModel,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
app_env_configs.storyteller_host.clone(),
api_host.clone(),
creds.clone(),
));
@ -70,6 +70,12 @@ pub(super) async fn handle_artcraft_video_via_router(
.map(|p| p.inference_job_token.to_string())
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
info!(
"Router video generation enqueued: inference_job_token={}, response={:?}",
job_id,
response,
);
Ok(TaskEnqueueSuccess {
task_type: TaskType::VideoGeneration,
model: Some(generation_model),

View File

@ -1,3 +1,3 @@
mod get_storyteller_creds_or_error;
mod handle_artcraft_video_via_router;
pub (super) mod handle_artcraft_video;
pub mod get_storyteller_creds_or_error;
pub mod handle_artcraft_video_via_router;
pub mod handle_artcraft_video;

View File

@ -1,29 +1,19 @@
use crate::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
use crate::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::commands::generate::generate_video::artcraft::handle_artcraft_video::handle_video_artcraft;
use crate::commands::generate::generate_video::grok::handle_grok_video::handle_grok_video;
use crate::commands::generate::generate_video::handle_credential_router::handle_credential_router;
use crate::commands::generate::generate_video::request::{
TauriGenerateVideoErrorType, TauriGenerateVideoRequest, TauriGenerateVideoResponse,
TauriVideoModel,
};
use crate::commands::generate::generate_video::sora2::handle_sora_sora2::handle_sora_sora2;
use crate::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::commands::response::shorthand::Response;
use crate::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
use crate::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::state::task_database::TaskDatabase;
use crate::services::grok::state::grok_credential_manager::GrokCredentialManager;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use log::{error, info, warn};
use tauri::{AppHandle, State};
@ -31,15 +21,9 @@ use tauri::{AppHandle, State};
pub async fn generate_video_command(
mut request: TauriGenerateVideoRequest,
app: AppHandle,
app_env_configs: State<'_, AppEnvConfigs>,
app_data_root: State<'_, AppDataRoot>,
artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>,
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
grok_creds_manager: State<'_, GrokCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
sora_creds_manager: State<'_, SoraCredentialManager>,
) -> Response<TauriGenerateVideoResponse, TauriGenerateVideoErrorType, ()> {
info!("generate_video_command called, request: {:?}", request);
@ -58,15 +42,9 @@ pub async fn generate_video_command(
let result = handle_request(
request,
&app,
&app_env_configs,
&app_data_root,
&artcraft_usage_tracker,
&provider_priority_store,
&task_database,
&grok_creds_manager,
&sora_creds_manager,
&storyteller_creds_manager,
).await;
match result {
@ -77,35 +55,43 @@ pub async fn generate_video_command(
let mut status = CommandErrorStatus::ServerError;
let mut error_type = TauriGenerateVideoErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.".to_string();
match err {
GenerateError::BadInput(BadInputReason::NoModelSpecified) => {
status = CommandErrorStatus::BadRequest;
error_type = TauriGenerateVideoErrorType::ModelNotSpecified;
error_message = "No model specified for video generation";
error_message = "No model specified for video generation".to_string();
}
GenerateError::NoProviderAvailable => {
status = CommandErrorStatus::ServerError;
error_type = TauriGenerateVideoErrorType::NoProviderAvailable;
error_message = "No configured provider available for video generation";
error_message = "No configured provider available for video generation".to_string();
}
GenerateError::CredentialProblem(_) => {
status = CommandErrorStatus::Unauthorized;
error_type = TauriGenerateVideoErrorType::CredentialProblem;
error_message = "There's a problem with the selected account.".to_string();
}
GenerateError::NotYetImplemented(message) => {
error_message = message;
}
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => {
status = CommandErrorStatus::Unauthorized;
error_type = TauriGenerateVideoErrorType::NeedsFalApiKey;
error_message = "You need to set a FAL api key";
error_message = "You need to set a FAL api key".to_string();
},
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials) => {
status = CommandErrorStatus::Unauthorized;
error_type = TauriGenerateVideoErrorType::NeedsStorytellerCredentials;
error_message = "You need to be logged into Artcraft.";
error_message = "You need to be logged into Artcraft.".to_string();
}
_ => {}, // Fall-through
}
Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_message: Some(error_message),
error_type: Some(error_type),
error_details: None,
})
@ -130,63 +116,14 @@ pub async fn generate_video_command(
async fn handle_request(
request: TauriGenerateVideoRequest,
app: &AppHandle,
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
artcraft_usage_tracker: &ArtcraftUsageTracker,
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
grok_creds_manager: &GrokCredentialManager,
sora_creds_manager: &SoraCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let model = match request.model {
Some(model) => model,
None => {
return Err(GenerateError::no_model_specified())
}
};
let provider = match (model, request.provider) {
(TauriVideoModel::GrokVideo, _) => GenerationProvider::Grok,
// NB: Grok Imagine 1.5 is served via Artcraft (storyteller-web / artcraft_router)
// for now — force it regardless of the requested provider. In the future we
// may serve it natively via the Grok provider.
(TauriVideoModel::GrokImagineVideo1p5, _) => GenerationProvider::Artcraft,
_ => request.provider.unwrap_or(GenerationProvider::Artcraft),
};
info!("generate video with {:?} via provider {:?}", &model, &provider);
let result = match provider {
GenerationProvider::Grok => {
handle_grok_video(
&request,
app,
app_data_root,
app_env_configs,
grok_creds_manager,
).await
}
GenerationProvider::Sora => {
handle_sora_sora2(
&request,
app,
app_data_root,
app_env_configs,
sora_creds_manager,
).await
}
_ => {
handle_video_artcraft(
&request,
app,
app_env_configs,
storyteller_creds_manager,
).await
}
};
// Generation is credential-driven: the request names a stored credential,
// and the router dispatches to that credential's service.
let result = handle_credential_router(&request, app_data_root).await;
let success_event = match result {
Err(err) => return Err(err),

View File

@ -0,0 +1,70 @@
use log::info;
use crate::api_adapters::models::video::tauri_video_model_to_generation_model::tauri_video_model_to_generation_model;
use crate::api_adapters::models::video::tauri_video_model_to_router_model::tauri_video_model_to_router_model;
use crate::commands::enqueue::generate_error::GenerateError;
use crate::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::commands::generate::common::generation_credential::{credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential};
use crate::commands::generate::generate_video::artcraft::handle_artcraft_video_via_router::handle_artcraft_video_via_router;
use crate::commands::generate::generate_video::request::TauriGenerateVideoRequest;
use crate::credentials::artcraft_api_host::maybe_artcraft_api_host_for_service;
use crate::credentials::credential::Credential;
use crate::credentials::credential_service_type::CredentialServiceType;
use crate::state::data_dir::app_data_root::AppDataRoot;
/// Credential-driven video generation: resolve the stored credential named
/// by the request's `credential_id`, then route to that credential's service
/// via the router. The router owns upload + dispatch mechanics per provider.
pub async fn handle_credential_router(
request: &TauriGenerateVideoRequest,
app_data_root: &AppDataRoot,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let credential = resolve_generation_credential(
request.credential_id.as_deref(),
app_data_root,
)?;
info!(
"handle_credential_router (video): credential={} service={}",
credential.id.as_str(),
credential.service,
);
match credential.service {
CredentialServiceType::Artcraft
| CredentialServiceType::ArtcraftLocal
| CredentialServiceType::ArtcraftCookies => {
handle_artcraft_credential(request, &credential).await
}
other => Err(credential_not_usable(
&credential,
&format!("accounts for service {} can't generate videos yet", other),
)),
}
}
/// Generate via the router's Artcraft provider, authenticating with the
/// stored session cookies. Video requests carry media tokens only, which
/// Artcraft accepts directly — no pre-upload step is needed.
async fn handle_artcraft_credential(
request: &TauriGenerateVideoRequest,
credential: &Credential,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?;
let router_model = tauri_video_model_to_router_model(tauri_model);
let generation_model = tauri_video_model_to_generation_model(tauri_model);
let api_host = maybe_artcraft_api_host_for_service(credential.service)
.expect("caller only routes ArtCraft services here");
let creds = storyteller_creds_from_credential(credential)?;
handle_artcraft_video_via_router(
request,
&api_host,
&creds,
router_model,
generation_model,
).await
}

View File

@ -1,5 +1,6 @@
pub mod generate_video_command;
mod handle_credential_router;
pub mod request;
mod artcraft;
mod grok;
pub mod artcraft;
pub mod grok;
pub mod sora2;

View File

@ -84,6 +84,11 @@ pub enum TauriVideoModel {
#[derive(Deserialize, Debug, Clone)]
pub struct TauriGenerateVideoRequest {
/// Stable id (`credential_{entropy}`) of the stored credential (account)
/// to generate with. Loaded from disk; generation routes to the
/// credential's service.
pub credential_id: Option<String>,
pub provider: Option<GenerationProvider>,
pub model: Option<TauriVideoModel>,
@ -149,4 +154,7 @@ pub enum TauriGenerateVideoErrorType {
NeedsFalApiKey,
FalError,
NeedsStorytellerCredentials,
/// Problem with the selected account credential (absent, unknown, or
/// unusable). The backend also flashes a dismissable modal.
CredentialProblem,
}

View File

@ -26,6 +26,7 @@ import { GenerationProvider } from "@storyteller/api-enums";
import { Clapperboard } from "lucide-react";
import { PromptShell, useComposerTasks } from "~/components/PromptShell";
import { AccountSelector } from "~/components/account-selector/AccountSelector";
import { useAccountSelectorStore } from "~/components/account-selector/accountSelectorStore";
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
@ -39,6 +40,7 @@ interface ImageToVideoProps {
const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const imageToVideoModelList = useImageToVideoPageModelList();
const startBatch = useImageToVideoStore((s) => s.startBatch);
const selectedAccountId = useAccountSelectorStore((s) => s.selectedAccountId);
const completeBatch = useImageToVideoStore((s) => s.completeBatch);
const selectedVideoModel: VideoModel | undefined =
@ -93,6 +95,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
uploadAudio={UploadAudioMedia}
credits={videoCredits}
accountSelector={<AccountSelector />}
credentialId={selectedAccountId}
modelSelector={
<ClassyModelSelector
variant="embedded"

View File

@ -132,6 +132,10 @@ interface PromptBoxVideoProps {
/** Optional account-picker slot rendered at the very start of the toolbar
* (left of the model selector). */
accountSelector?: React.ReactNode;
/** Stable id (`credential_{entropy}`) of the selected account credential,
* sent with generation requests so the backend knows which stored
* credential to generate with. */
credentialId?: string | null;
/** Optional model-picker slot rendered at the start of the toolbar
* (left of the aspect-ratio picker). */
modelSelector?: React.ReactNode;
@ -152,6 +156,7 @@ export const PromptBoxVideo = ({
uploadAudio,
credits,
accountSelector,
credentialId,
modelSelector,
fullBleed = false,
}: PromptBoxVideoProps) => {
@ -1080,6 +1085,10 @@ export const PromptBoxVideo = ({
frontend_subscriber_id: subscriberId,
};
if (!!credentialId) {
request.credential_id = credentialId;
}
if (!!selectedProvider) {
request.provider = selectedProvider;
}
@ -1208,7 +1217,11 @@ export const PromptBoxVideo = ({
await Promise.all(enqueuePromises);
} catch (err) {
console.error("PromptBoxVideo - enqueue failed", err);
toast.error("Failed to start video generation. Please try again.");
const backendMessage = (err as { error_message?: string } | null)
?.error_message;
toast.error(
backendMessage || "Failed to start video generation. Please try again.",
);
}
onEnqueuePressed?.(prompt, subscriberIds);

View File

@ -8,6 +8,11 @@ import {
import { GenerationProvider } from "@storyteller/api-enums";
export interface GenerateVideoRequest {
// Stable id (`credential_{entropy}`) of the stored credential (account)
// to generate with. The backend loads it from disk and routes to that
// credential's service.
credential_id?: string;
// The provider to use (defaults to Artcraft/Storyteller).
provider?: GenerationProvider;
@ -50,6 +55,7 @@ export interface GenerateVideoRequest {
}
interface RawGenerateVideoRequest {
credential_id?: string;
provider?: GenerationProvider;
model?: string;
prompt?: string;
@ -117,6 +123,7 @@ export const GenerateVideo = async (
model: modelName,
};
if (!!request.credential_id) mutableRequest.credential_id = request.credential_id;
if (!!request.provider) mutableRequest.provider = request.provider;
if (!!request.prompt) mutableRequest.prompt = request.prompt;
if (!!request.negative_prompt) mutableRequest.negative_prompt = request.negative_prompt;