Compare commits

...
17 changed files with 295 additions and 1010 deletions
@@ -3,7 +3,11 @@ use std::error::Error;
use std::str::FromStr;
/// Easy to use Result<T, E> type.
pub type CommandResult<T, E> = Result<CommandSuccessResponseWrapper<T>, CommandErrorResponseWrapper<E>>;
pub type CommandResult<SuccessPayload, ErrType, ErrPayload> =
Result<CommandSuccessResponseWrapper<SuccessPayload>, CommandErrorResponseWrapper<ErrType, ErrPayload>>;
/// No inner payloads for this type. Just strings as messages.
pub type SimpleCommandResult = CommandResult<(), (), ()>;
/// Statuses for successful commands.
#[derive(Serialize, Clone, Debug)]
@@ -18,6 +22,7 @@ pub enum CommandSuccessStatus {
pub enum CommandErrorStatus {
BadRequest, // 400
Unauthorized, // 401
TooManyRequests, // 429
ServerError, // 500
}
@@ -34,15 +39,22 @@ pub struct CommandSuccessResponseWrapper<T: Serialize> {
}
#[derive(Serialize, Debug)]
pub struct CommandErrorResponseWrapper<T: Serialize> {
pub struct CommandErrorResponseWrapper<ErrType: Serialize, ErrPayload: Serialize> {
/// Both "success" and "error" types have a `status` field for the frontend.
pub status: CommandErrorStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
/// Represents a "type" of error, preferably an enum without payloads.
/// Preferably this serializes to a string.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_details: Option<T>,
pub error_type: Option<ErrType>,
/// Represents a full error details object or payload.
/// This could be a structure, message, or whatever.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_details: Option<ErrPayload>,
}
@@ -96,11 +108,12 @@ impl From<&str> for CommandSuccessResponseWrapper<String> {
}
}
impl From<&str> for CommandErrorResponseWrapper<String> {
impl From<&str> for CommandErrorResponseWrapper<(), ()> {
fn from(value: &str) -> Self {
CommandErrorResponseWrapper {
status: CommandErrorStatus::BadRequest, // NB: Default to 500 error.
error_message: Some(value.to_string()),
error_type: None,
error_details: None,
}
}
@@ -1,4 +1,4 @@
use crate::commands::command_response_wrapper::CommandResult;
use crate::commands::command_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus, CommandResult};
use crate::events::sendable_event_trait::SendableEvent;
use crate::events::sora::sora_image_enqueue_failure_event::SoraImageEnqueueFailureEvent;
use crate::events::sora::sora_image_enqueue_success_event::SoraImageEnqueueSuccessEvent;
@@ -12,7 +12,7 @@ use crate::utils::get_url_file_extension::get_url_file_extension;
use crate::utils::simple_http_download::simple_http_download;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use errors::AnyhowResult;
use errors::{AnyhowError, AnyhowResult};
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{debug, error, info};
@@ -31,6 +31,9 @@ use std::io::Cursor;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use openai_sora_client::sora_error::SoraError;
use storyteller_client::api_error::ApiError;
use storyteller_client::api_error::ApiError::InternalServerError;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
@@ -50,6 +53,20 @@ pub struct SoraImageRemixCommand {
pub maybe_number_of_samples: Option<u32>,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum SoraImageRemixErrorType {
/// Generic server error
ServerError,
/// The user is sending too many requests
TooManyConcurrentTasks,
/// The user needs to create a Sora account
SoraUsernameNotYetCreated,
/// The Sora service is having problems. Try again soon.
SoraIsHavingProblems,
}
#[tauri::command]
pub async fn sora_image_remix_command(
app: AppHandle,
@@ -57,7 +74,7 @@ pub async fn sora_image_remix_command(
app_data_root: State<'_, AppDataRoot>,
sora_creds_manager: State<'_, SoraCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> CommandResult<(), String> {
) -> CommandResult<(), SoraImageRemixErrorType, ()> {
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}",
request.snapshot_media_token, request.maybe_additional_images);
@@ -66,27 +83,83 @@ pub async fn sora_image_remix_command(
let result = generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue).await;
if let Err(err) = result {
error!("error: {:?}", err);
match result {
Err(err) => {
error!("error: {:?}", err);
let event = SoraImageEnqueueFailureEvent {};
let result = event.send(&app);
let event = SoraImageEnqueueFailureEvent {};
let result = event.send(&app);
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
}
let mut status = CommandErrorStatus::ServerError;
let mut error_type = SoraImageRemixErrorType::ServerError;
let mut error_message = "A server error occurred.";
match (err) {
InnerError::SoraError(SoraError::TooManyConcurrentTasks) => {
error_type = SoraImageRemixErrorType::TooManyConcurrentTasks;
status = CommandErrorStatus::ServerError;
error_message = "You already have work in progress. Please wait.";
},
InnerError::SoraError(SoraError::SoraUsernameNotYetCreated) => {
error_type = SoraImageRemixErrorType::SoraUsernameNotYetCreated;
status = CommandErrorStatus::BadRequest;
error_message = "You need to create a username on Sora.com.";
},
InnerError::SoraError(SoraError::BadGateway(_) | SoraError::CloudFlareTimeout(_)) => {
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
status = CommandErrorStatus::ServerError;
error_message = "Sora is having problems. Please wait a moment before retrying.";
}
_ => {},
}
Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
Ok(_) => {
let event = SoraImageEnqueueSuccessEvent {};
let result = event.send(&app);
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
}
Ok(().into())
}
return Err("there was an error".into());
}
}
let event = SoraImageEnqueueSuccessEvent {};
let result = event.send(&app);
#[derive(Debug)]
enum InnerError {
SoraError(SoraError),
AnyhowError(AnyhowError),
StorytellerApiError(ApiError),
}
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
impl From<AnyhowError> for InnerError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
Ok(().into())
impl From<SoraError> for InnerError {
fn from(value: SoraError) -> Self {
Self::SoraError(value)
}
}
impl From<ApiError> for InnerError {
fn from(value: ApiError) -> Self {
Self::StorytellerApiError(value)
}
}
pub async fn generate_image(
@@ -94,7 +167,7 @@ pub async fn generate_image(
app_data_root: &AppDataRoot,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> AnyhowResult<()> {
) -> Result<(), InnerError> {
let response = get_media_file(&ApiHost::Storyteller, &request.snapshot_media_token).await?;
@@ -1,5 +1,5 @@
use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload;
use crate::commands::command_response_wrapper::{CommandResult, CommandSuccessResponseWrapper};
use crate::commands::command_response_wrapper::{CommandResult, CommandSuccessResponseWrapper, SimpleCommandResult};
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
@@ -17,7 +17,7 @@ use tauri::{AppHandle, State};
#[tauri::command]
pub async fn sora_logout_command(
sora_creds_manager: State<'_, SoraCredentialManager>,
) -> CommandResult<(), String> {
) -> SimpleCommandResult {
info!("sora_logout_command called");
if let Err(err) = sora_creds_manager.purge_credentials_from_disk() {
@@ -32,4 +32,3 @@ pub async fn sora_logout_command(
Ok(().into())
}
@@ -39,11 +39,10 @@ pub async fn list_sora_task_status_with_session_auto_renew(
new_creds = Some(updated_creds);
}
let legacy_creds = new_creds.as_ref()
.unwrap_or_else(|| &args.credentials)
.to_legacy_credentials()?;
let upgraded_creds = new_creds.as_ref()
.unwrap_or_else(|| &args.credentials);
let result = get_image_gen_status(&request, &legacy_creds).await;
let result = get_image_gen_status(&request, &upgraded_creds).await;
let err = match result {
Ok(response) => return Ok((response, new_creds)),
@@ -56,7 +55,7 @@ pub async fn list_sora_task_status_with_session_auto_renew(
info!("Retrying image upload with new credentials...");
let result = get_image_gen_status(&request, &legacy_creds).await?;
let result = get_image_gen_status(&request, &upgraded_creds).await?;
Ok((result, Some(new_creds)))
}
@@ -2,6 +2,7 @@ use crate::credentials::SoraCredentials;
use crate::requests::image_gen::image_gen_status::TaskStatus;
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse};
use errors::AnyhowResult;
use crate::creds::sora_credential_set::SoraCredentialSet;
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
let status_request = StatusRequest {
@@ -10,11 +11,13 @@ pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCrede
// before: Some(task_id.clone()),
};
let retry_limit = retry_limit.unwrap_or(10);
let new_creds = SoraCredentialSet::from_legacy_credentials(&credentials)?;
for _ in 0..retry_limit {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let status_response = get_image_gen_status(&status_request, credentials).await?;
let status_response = get_image_gen_status(&status_request, &new_creds).await?;
match find_task_response_by_id(&status_response, task_id.clone()) {
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
@@ -1,3 +1,4 @@
use log::warn;
use crate::creds::credential_migration::CredentialMigrationRef;
use serde_derive::{Deserialize, Serialize};
use thiserror::Error;
@@ -200,6 +201,8 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
if status != reqwest::StatusCode::OK {
warn!("Sora image generation failure. Raw response: {:?}", response_body);
let error_response: RawSoraErrorResponse = serde_json::from_str(response_body)
.map_err(|e| SoraImageGenError::GenericError(format!("Failed to parse error response: {}", e)))?;
@@ -6,7 +6,9 @@ use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::{fs::File, io::Write, path::Path};
use log::debug;
use log::{debug, warn};
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen";
@@ -171,7 +173,7 @@ pub struct InpaintItem {
pub r#type: String,
pub preset_id: Option<String>,
pub generation_id: Option<String>,
pub upload_media_id: String,
pub upload_media_id: Option<String>,
pub frame_index: i32,
pub source_start_frame: i32,
}
@@ -199,7 +201,16 @@ impl StatusRequest {
}
/// Gets the status of image generation tasks from Sora API
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentials) -> Result<VideoGenStatusResponse, SoraError> {
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentialSet) -> Result<VideoGenStatusResponse, SoraError> {
let bearer_header = match credentials.jwt_bearer_token.as_ref() {
Some(bearer) => bearer.to_authorization_header_value(),
None => {
warn!("No JWT bearer for getting image gen status!");
return Err(SoraError::NoBearerTokenAvailable)
},
};
let client = reqwest::Client::new();
let mut url = reqwest::Url::parse(SORA_STATUS_URL)
@@ -216,8 +227,8 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
let http_request = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value())
.header("Cookie", credentials.cookies.as_str())
.header("Authorization", bearer_header)
.header("Content-Type", "application/json");
let http_request = http_request.build()?;
@@ -235,9 +246,15 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
return Err(SoraError::AnyhowError(anyhow::anyhow!("The status request failed; status = {:?}, message = {:?}", status, response_body)));
}
let response: VideoGenStatusResponse = serde_json::from_str(response_body)?;
let maybe_response = serde_json::from_str::<VideoGenStatusResponse>(response_body);
Ok(response)
match maybe_response {
Ok(response) => Ok(response),
Err(err) => {
warn!("Failed to parse status response: {:?}", err);
Err(SoraError::JsonErrorWithBody(err, response_body.to_string()))
}
}
}
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
@@ -275,6 +292,7 @@ mod tests {
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
use crate::creds::sora_credential_set::SoraCredentialSet;
#[ignore]
#[tokio::test]
@@ -297,10 +315,12 @@ mod tests {
let bearer = bearer.trim().to_string();
let creds = SoraCredentials { bearer_token: bearer, cookie, sentinel: Some(sentinel) };
let new_creds = SoraCredentialSet::from_legacy_credentials(&creds)?;
// Get the task status for a specific task
// let response = get_image_gen_status(&StatusRequest::new(None, Some("task_01jr9yvpfyetx9r7qvvx38scna".to_string())), &creds).await?;
let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &creds).await?;
let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &new_creds).await?;
println!("task_id: {}", response.task_responses[0].id);
let task_id = response.task_responses[0].id.clone();
@@ -62,6 +62,10 @@ pub enum SoraError {
/// serde_json::Error, likely from JSON deserialization schema mismatch.
JsonError(serde_json::Error),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
JsonErrorWithBody(serde_json::Error, String),
}
impl Error for SoraError {}
@@ -108,6 +112,9 @@ impl Display for SoraError {
Self::JsonError(err) => {
write!(f, "serde_json error: {}", err)
}
Self::JsonErrorWithBody(err, message) => {
write!(f, "serde_json error: {:?} body: {:?}", err, message)
}
}
}
}
@@ -320,7 +320,7 @@ export const PromptBox = () => {
if (!prompt.trim()) return;
try {
console.log(
"Enqueuing with prompt:",
"(3) Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages,
@@ -0,0 +1,36 @@
// This is used to do things such as disable job polling in the dev console.
export const SetDevFlag = (flagName: string, value: any) => {
if (!!!(window as any)._dev_flags) {
(window as any)._dev_flags = {};
}
(window as any)._dev_flags[flagName] = value;
}
export const GetDevFlag = (flagName: string) => {
if (!!(window as any)._dev_flags && flagName in (window as any)._dev_flags) {
return (window as any)._dev_flags[flagName];
}
return null;
}
export const DisableJobPolling = () => {
SetDevFlag("disable_job_polling", true);
}
export const EnableJobPolling = () => {
SetDevFlag("disable_job_polling", false);
}
export const IsJobPollingDisabled = () : boolean => {
return GetDevFlag("disable_job_polling") === true;
}
(window as any).setDevFlag = SetDevFlag;
(window as any).getDevFlag = GetDevFlag;
(window as any).disableJobPolling = DisableJobPolling;
(window as any).enableJobPolling = EnableJobPolling;
@@ -1,9 +1,15 @@
import { ToastTypes } from "~/enums";
import { addToast, setJobs } from "~/signals";
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
import { IsJobPollingDisabled } from "~/flags/DevConsoleFlags";
export async function PollRecentJobs() {
const jobsApi = new JobsApi();
if (IsJobPollingDisabled()) {
return;
}
const response = await jobsApi.ListRecentJobs();
if (response.success && response.data) {
setJobs(response.data);
@@ -9,6 +9,7 @@ import { useEffect, useState, useRef, useCallback } from "react";
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
import { toast } from "@storyteller/ui-toaster";
import { ActiveJob } from "~/pages/PageEnigma/models";
import { IsJobPollingDisabled } from "~/flags/DevConsoleFlags";
// TODO ensure we de-dupe all this extra code.
interface CompletedItem {
token: string;
@@ -85,6 +86,9 @@ export function Activity() {
let mounted = true;
async function fetchJobs() {
if (IsJobPollingDisabled()) {
return;
}
try {
const jobsApi = new JobsApi();
const response = await jobsApi.ListRecentJobs();
@@ -1,936 +0,0 @@
import { useState, useRef, useEffect, useContext } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { invoke } from "@tauri-apps/api/core";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { downloadFileFromUrl } from "@storyteller/api";
import {
faPlus,
faCamera,
faSave,
faSparkles,
faUpload,
faImages,
faDownload,
faSpinnerThird,
faTimes,
faMessageCheck,
faMessageXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleWide,
faRectangleVertical,
faSquare,
faRectangle,
faTableCellsLarge,
} from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem, PopoverMenu } from "~/components/reusable/Popover";
import { Button, ToggleButton, Tooltip } from "~/components";
import { CameraAspectRatio } from "~/pages/PageEnigma/enums";
import {
cameraAspectRatio,
disableHotkeyInput,
DomLevels,
enableHotkeyInput,
gridVisibility,
setGridVisibility,
} from "~/pages/PageEnigma/signals";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { PromptsApi } from "@storyteller/api";
import { SoundRegistry } from "@storyteller/soundboard";
import { GetAppPreferences, SoraImageRemix } from "@storyteller/tauri-api";
import { CommandSuccessStatus } from "@storyteller/tauri-api";
import {
cameras,
selectedCameraId,
addCamera,
deleteCamera,
updateCamera,
focalLengthDragging,
} from "~/pages/PageEnigma/signals/camera";
import { toast } from "@storyteller/ui-toaster";
import { isPromptBoxFocused } from "~/pages/PageEnigma/signals/promptBox";
interface ReferenceImage {
id: string;
url: string;
file: File;
mediaToken: string;
}
import { EngineApi } from "~/Classes/ApiManager/EngineApi";
import { UploaderStates } from "~/enums/UploaderStates";
import { CameraSettingsModal } from "../CameraSettingsModal";
import { twMerge } from "tailwind-merge";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { Modal } from "@storyteller/ui-modal";
export const PromptBox = () => {
useSignals();
const editorEngine = useContext(EngineContext);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [content, setContent] = useState<React.ReactNode>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [isEnqueueing, setisEnqueueing] = useState(false);
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "3:2",
selected: true,
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
},
{
label: "2:3",
selected: false,
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
},
{
label: "1:1",
selected: false,
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
},
]);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [prompt]);
// Update aspect ratio list based on the current cameraAspectRatio signal
useEffect(() => {
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected:
(item.label === "16:9" &&
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_16_9) ||
(item.label === "3:2" &&
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_3_2) ||
(item.label === "2:3" &&
cameraAspectRatio.value === CameraAspectRatio.VERTICAL_2_3) ||
(item.label === "1:1" &&
cameraAspectRatio.value === CameraAspectRatio.SQUARE_1_1),
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraAspectRatio.value]);
const handleCameraSelect = (selectedItem: PopoverItem) => {
const selectedCamera = cameras.value.find(
(cam) => cam.label === selectedItem.label,
);
if (selectedCamera && editorEngine) {
selectedCameraId.value = selectedCamera.id;
// Show focal length display temporarily
// TODO: Rename dragging to visible - BFlat
focalLengthDragging.value = {
isDragging: true,
focalLength: selectedCamera.focalLength,
};
setTimeout(() => {
focalLengthDragging.value = {
isDragging: false,
focalLength: selectedCamera.focalLength,
};
}, 1500);
// Update the main camera to match the selected camera's properties
if (editorEngine.camera) {
// First update position and lookAt
editorEngine.camera.position.set(
selectedCamera.position.x,
selectedCamera.position.y,
selectedCamera.position.z,
);
editorEngine.camera.lookAt(
selectedCamera.lookAt.x,
selectedCamera.lookAt.y,
selectedCamera.lookAt.z,
);
// Update FOV
editorEngine.camera.fov = editorEngine.focalLengthToFov(
selectedCamera.focalLength,
);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
// Queue an update to ensure the engine processes the camera change
// Queue.publish({
// queueName: QueueNames.TO_ENGINE,
// action: toEngineActions.CAMERA_CHANGED,
// data: selectedCamera,
// });
}
// Force update camera properties in the state
updateCamera(selectedCamera.id, {
focalLength: selectedCamera.focalLength,
position: selectedCamera.position,
rotation: selectedCamera.rotation,
lookAt: selectedCamera.lookAt,
});
}
};
const handleAddCamera = () => {
// Check if we've reached the maximum number of cameras
if (cameras.value.length >= 6) {
console.warn("Maximum number of cameras (6) reached");
return;
}
const newIndex = cameras.value.length + 1;
const newId = `cam${newIndex}`;
// This is for generating random orbital position for the new camera using spherical coordinates
const radius = Math.random() * 5 + 7; // Distance from center: 7 to 12 units
const theta = Math.random() * Math.PI * 2; // Azimuthal angle: 0 to 2π
const phi = Math.PI / 3 + (Math.random() * Math.PI) / 6; // Polar angle: π/3 to π/2 (60° to 90° from horizontal)
// Convert spherical coordinates to Cartesian coordinates
const randomX = radius * Math.sin(phi) * Math.cos(theta);
const randomY = Math.abs(radius * Math.cos(phi)) + 2; // Ensure Y is positive and at least 2 units up
const randomZ = radius * Math.sin(phi) * Math.sin(theta);
addCamera({
id: newId,
label: `Camera ${newIndex}`,
focalLength: 24,
position: {
x: randomX,
y: randomY,
z: randomZ,
},
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
});
// Switch to the newly created camera
selectedCameraId.value = newId;
// Update the engine camera to match the new camera's properties
if (editorEngine && editorEngine.camera) {
editorEngine.camera.position.set(randomX, randomY, randomZ);
editorEngine.camera.lookAt(0, 0, 0);
editorEngine.camera.fov = editorEngine.focalLengthToFov(24);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
// Queue.publish({
// queueName: QueueNames.TO_ENGINE,
// action: toEngineActions.CAMERA_CHANGED,
// data: {
// id: newId,
// label: `Camera ${newIndex}`,
// focalLength: 24,
// position: { x: randomX, y: randomY, z: randomZ },
// rotation: { x: 0, y: 0, z: 0 },
// lookAt: { x: 0, y: 0, z: 0 },
// },
// });
}
};
const handleCameraNameChange = (id: string, newName: string) => {
updateCamera(id, { label: newName });
};
const handleCameraFocalLengthChange = (id: string, value: number) => {
const camera = cameras.value.find((cam) => cam.id === id);
if (camera) {
updateCamera(id, { focalLength: value });
}
};
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
);
// Map the selected label to the corresponding CameraAspectRatio enum value
let newRatio: CameraAspectRatio;
switch (selectedItem.label) {
case "16:9":
newRatio = CameraAspectRatio.HORIZONTAL_16_9;
break;
case "3:2":
newRatio = CameraAspectRatio.HORIZONTAL_3_2;
break;
case "2:3":
newRatio = CameraAspectRatio.VERTICAL_2_3;
break;
case "1:1":
newRatio = CameraAspectRatio.SQUARE_1_1;
break;
default:
newRatio = CameraAspectRatio.HORIZONTAL_16_9;
}
// Publish the change to the engine
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.CHANGE_CAMERA_ASPECT_RATIO,
data: newRatio,
});
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
Array.from(files).forEach((file) => {
const uploadId = Math.random().toString(36).substring(7);
setUploadingImages((prev) => [...prev, { id: uploadId, file }]);
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
console.debug("Upload progress:", newState.data);
if (newState.status === UploaderStates.success && newState.data) {
const referenceImage: ReferenceImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: newState.data || "",
};
setReferenceImages((prev) => [...prev, referenceImage]);
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId),
);
console.log("Reference image added:", referenceImage);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId),
);
console.error("Upload failed");
}
},
});
};
reader.readAsDataURL(file);
});
}
};
const handleRemoveReference = (id: string) => {
setReferenceImages((prev) => prev.filter((img) => img.id !== id));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleGallerySelect = () => {
setIsGalleryModalOpen(true);
};
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
if (prev.length >= 4) {
return prev;
}
return [...prev, id];
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
selectedItems.forEach((item) => {
if (!item.fullImage) return;
const referenceImage: ReferenceImage = {
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "gallery-image"),
mediaToken: item.id,
};
setReferenceImages((prev) => [...prev, referenceImage]);
});
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleAction = (action: string) => {
switch (action) {
case "upload":
handleUploadClick();
break;
case "gallery":
handleGallerySelect();
break;
default:
console.log("Unknown action:", action);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text").trim();
setPrompt(pastedText);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
};
const handleError = (errorMessage: string) => {
setContent(errorMessage);
setIsModalOpen(true);
};
const handleEnqueue = async () => {
const isDesktop = IsDesktopApp();
console.log("Is this a desktop app?", isDesktop);
const r = SoundRegistry.getInstance();
r.playSound("test");
if (isDesktop) {
await handleTauriEnqueue();
} else {
await handleWebEnqueue();
}
};
const handleWebEnqueue = async () => {
if (!prompt.trim()) return;
setisEnqueueing(true);
if (editorEngine) {
editorEngine.positive_prompt = prompt;
//const engineApi = new EngineApi();
const promptsApi = new PromptsApi();
const snapshot = editorEngine.snapShotOfCurrentFrame(false);
if (snapshot) {
const snapshotResult = await promptsApi.uploadSceneSnapshot({
screenshot: snapshot.file,
sceneMediaToken: "",
});
console.log("useSystemPrompt", useSystemPrompt);
const response = await promptsApi.enqueueImageGeneration({
disableSystemPrompt: !useSystemPrompt,
prompt: prompt,
snapshotMediaToken: snapshotResult.data || "",
additionalImages: referenceImages.map((image) => image.mediaToken),
});
console.log("response", response);
if (response.errorMessage) {
handleError(response.errorMessage);
setisEnqueueing(false);
return;
}
}
try {
// Here we would pass both the prompt and reference images to the generation
console.log(
"Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages,
);
await new Promise((resolve) => setTimeout(resolve, 2000));
} finally {
setisEnqueueing(false);
toast.success("Image added to queue");
}
}
setisEnqueueing(false);
};
const handleTauriEnqueue = async () => {
if (!prompt.trim()) return;
setisEnqueueing(true);
setTimeout(() => {
// TODO(bt,2025-05-08): This is a hack so we don't accidentally wind up with a permanently disabled prompt box if
// the backend hangs on a given request. (Sometimes Sora doesn't respond.) Ideally what we would do instead is only
// force the prompt box to be enabled again as long as another request isn't running, ie. if we just fired off two
// requests in succession, we wouldn't want to turn it off as the first one gets submitted.
console.debug('Turn off blocking of prompt box...');
setisEnqueueing(false);
}, 10000);
if (editorEngine) {
editorEngine.positive_prompt = prompt;
const promptsApi = new PromptsApi();
const snapshot = editorEngine.snapShotOfCurrentFrame(false);
if (snapshot) {
const snapshotResult = await promptsApi.uploadSceneSnapshot({
screenshot: snapshot.file,
//sceneMediaToken: "",
});
console.log("snapshotResult", snapshotResult)
const generateResponse = await SoraImageRemix({
snapshot_media_token: snapshotResult.data!,
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
maybe_additional_images: referenceImages.map(
(image) => image.mediaToken,
),
maybe_number_of_samples: 1,
});
console.log("generateResponse", generateResponse)
if (generateResponse.status === CommandSuccessStatus.Success) {
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.enqueue_success_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.success("Image added to queue");
setisEnqueueing(false);
return;
} else {
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.enqueue_failure_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.error("Failed to enqueue image");
setisEnqueueing(false);
return;
}
// const generateResponse = await invoke("sora_image_remix_command", {
// request: {
// snapshot_media_token: snapshotResult.data!,
// disable_system_prompt: !useSystemPrompt,
// prompt: prompt,
// maybe_additional_images: referenceImages.map(
// (image) => image.mediaToken,
// ),
// maybe_number_of_samples: 1,
// },
// });
//if (generateResponse.errorMessage) {
// handleError(response.errorMessage);
// setisEnqueueing(false);
// return;
//}
}
try {
// Here we would pass both the prompt and reference images to the generation
console.log(
"Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages,
);
await new Promise((resolve) => setTimeout(resolve, 2000));
} finally {
setisEnqueueing(false);
toast.success("Image added to queue");
}
}
setisEnqueueing(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleWebEnqueue();
}
};
// Get the current aspect ratio icon based on the cameraAspectRatio signal
const getCurrentAspectRatioIcon = () => {
switch (cameraAspectRatio.value) {
case CameraAspectRatio.HORIZONTAL_3_2:
return faRectangle;
case CameraAspectRatio.VERTICAL_2_3:
return faRectangleVertical;
case CameraAspectRatio.VERTICAL_9_16:
return faRectangleVertical;
case CameraAspectRatio.SQUARE_1_1:
return faSquare;
default:
return faRectangleWide;
}
};
const handleSaveFrame = async () => {
if (editorEngine) {
const snapshot = editorEngine.snapShotOfCurrentFrame(false);
if (snapshot) {
const engineApi = new EngineApi();
await engineApi.uploadSceneSnapshot({
screenshot: snapshot.file || "",
sceneMediaToken: "",
});
}
}
};
const handleDownloadFrame = () => {
if (editorEngine) {
editorEngine.snapShotOfCurrentFrame(true);
}
};
return (
<>
<Modal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setContent("");
}}
>
{content}
</Modal>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
/>
</div>
</div>
);
})}
</div>
)}
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
isPromptBoxFocused.value ? "!border !border-primary" : "",
)}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "gallery",
},
]}
onPanelAction={handleAction}
showIconsInList
buttonClassName="bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
isPromptBoxFocused.value = true;
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
isPromptBoxFocused.value = false;
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Tooltip
content="Aspect ratio"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={aspectRatioList}
onSelect={handleAspectRatioSelect}
mode="toggle"
panelTitle="Aspect Ratio"
showIconsInList
triggerIcon={
<FontAwesomeIcon
icon={getCurrentAspectRatioIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content="Camera"
position="top"
className="z-50"
delay={300}
closeOnClick={true}
>
<PopoverMenu
items={cameras.value.map((cam) => ({
id: cam.id,
label: cam.label,
selected: cam.id === selectedCameraId.value,
icon: (
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
),
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}))}
onSelect={handleCameraSelect}
onAdd={handleAddCamera}
triggerIcon={
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
}
showAddButton
disableAddButton={cameras.value.length >= 6}
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
buttonClassName="max-w-[130px] h-9"
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
<Tooltip
content={gridVisibility.value ? "Grid: ON" : "Grid: OFF"}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={gridVisibility.value}
icon={faTableCellsLarge}
activeIcon={faTableCellsLarge}
onClick={() => setGridVisibility(!gridVisibility.value)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip
content="Download frame"
position="top"
className="z-50"
closeOnClick={true}
delay={200}
>
<Button
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip>
<Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button>
<Button
className="flex items-center border-none bg-brand-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
>
{isEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
onClose={() => setIsCameraSettingsOpen(false)}
cameras={cameras.value.map((cam) => ({
id: cam.id,
label: cam.label,
selected: cam.id === selectedCameraId.value,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}))}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId.value}
handleCameraSelect={handleCameraSelect}
onDeleteCamera={deleteCamera}
/>
</div>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
tabs={[
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
onDownloadClicked={downloadFileFromUrl}
/>
</>
);
};
@@ -1 +0,0 @@
export * from "./PromptBox";
@@ -357,7 +357,7 @@ export const PromptBox2D = ({
if (!prompt.trim()) return;
try {
console.log(
"Enqueuing with prompt:",
"(4) Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages
@@ -42,19 +42,20 @@ import { PromptsApi } from "@storyteller/api";
import { SoundRegistry } from "@storyteller/soundboard";
import { toast } from "@storyteller/ui-toaster";
interface ReferenceImage {
id: string;
url: string;
file: File;
mediaToken: string;
}
import { EngineApi } from "@storyteller/api";
import { CameraSettingsModal } from "@storyteller/ui-camera-settings-modal";
import { twMerge } from "tailwind-merge";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { Modal } from "@storyteller/ui-modal";
import { Signal } from "@preact/signals-react";
import { CommandSuccessStatus, GetAppPreferences, SoraImageRemix, SoraImageRemixErrorType } from "@storyteller/tauri-api";
interface ReferenceImage {
id: string;
url: string;
file: File;
mediaToken: string;
}
interface PromptBox3DProps {
cameras: Signal<Camera[]>;
@@ -317,14 +318,12 @@ export const PromptBox3D = ({
const handleEnqueue = async () => {
const isDesktop = IsDesktopApp();
console.log("Is this a desktop app?", isDesktop);
const r = SoundRegistry.getInstance();
r.playSound("test");
console.log("Is Desktop?", isDesktop);
if (isDesktop) {
console.log("Desktop app - tauri enqueue");
await handleTauriEnqueue();
} else {
console.log("Web app - web enqueue");
await handleWebEnqueue();
}
};
@@ -348,7 +347,7 @@ export const PromptBox3D = ({
sceneMediaToken: "",
});
console.log("useSystemPrompt", useSystemPrompt);
console.log("(web 3d) useSystemPrompt", useSystemPrompt);
const response = await promptsApi.enqueueImageGeneration({
disableSystemPrompt: !useSystemPrompt,
@@ -369,7 +368,7 @@ export const PromptBox3D = ({
try {
// Here we would pass both the prompt and reference images to the generation
console.log(
"Enqueuing with prompt:",
"(3D.2) Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages
@@ -388,6 +387,15 @@ export const PromptBox3D = ({
setisEnqueueing(true);
setTimeout(() => {
// TODO(bt,2025-05-08): This is a hack so we don't accidentally wind up with a permanently disabled prompt box if
// the backend hangs on a given request. (Sometimes Sora doesn't respond.) Ideally what we would do instead is only
// force the prompt box to be enabled again as long as another request isn't running, ie. if we just fired off two
// requests in succession, we wouldn't want to turn it off as the first one gets submitted.
console.debug('Turn off blocking of prompt box...');
setisEnqueueing(false);
}, 10000);
setPrompt(prompt);
const promptsApi = new PromptsApi();
@@ -401,31 +409,68 @@ export const PromptBox3D = ({
//sceneMediaToken: "",
});
const generateResponse = await invoke("sora_image_remix_command", {
request: {
snapshot_media_token: snapshotResult.data!,
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
maybe_additional_images: referenceImages.map(
(image) => image.mediaToken
),
maybe_number_of_samples: 1,
},
console.log("snapshotResult", snapshotResult);
const generateResponse = await SoraImageRemix({
snapshot_media_token: snapshotResult.data!,
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
maybe_additional_images: referenceImages.map(
(image) => image.mediaToken,
),
maybe_number_of_samples: 1,
});
console.log("response", generateResponse);
console.log("generateResponse", generateResponse);
if (generateResponse.status === CommandSuccessStatus.Success) {
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.enqueue_success_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.success("Image added to queue");
setisEnqueueing(false);
return;
} else {
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.enqueue_failure_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
let errorMessage = "Failed to enqueue image";
if ("error_type" in generateResponse) {
switch (generateResponse.error_type) {
case SoraImageRemixErrorType.ServerError:
errorMessage = "Server error. Failed to enqueue image.";
break;
case SoraImageRemixErrorType.TooManyConcurrentTasks:
errorMessage = "You have too many Sora image generation tasks running. Please wait a moment.";
break;
case SoraImageRemixErrorType.SoraUsernameNotYetCreated:
errorMessage = "Your Sora username is not yet created. Please visit the Sora.com website to create it first.";
break;
case SoraImageRemixErrorType.SoraIsHavingProblems:
errorMessage = "Sora is having problems. Please try again later.";
break;
}
}
toast.error(errorMessage);
setisEnqueueing(false);
return;
}
//if (generateResponse.errorMessage) {
// handleError(response.errorMessage);
// setisEnqueueing(false);
// return;
//}
}
try {
// Here we would pass both the prompt and reference images to the generation
console.log(
"Enqueuing with prompt:",
"(3D.1) Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages
@@ -443,7 +488,7 @@ export const PromptBox3D = ({
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleWebEnqueue();
handleEnqueue();
}
};
@@ -19,10 +19,24 @@ export interface SoraImageRemixRequest {
maybe_number_of_samples: number,
}
export interface SoraImageRemixResult extends CommandResult {
export interface SoraImageRemixSuccess extends CommandResult {
// TODO: Status fields (they're not on the backend yet)
}
export enum SoraImageRemixErrorType {
ServerError = "server_error",
TooManyConcurrentTasks = "too_many_concurrent_tasks",
SoraUsernameNotYetCreated = "sora_username_not_yet_created",
SoraIsHavingProblems = "sora_is_having_problems",
}
export interface SoraImageRemixError extends CommandResult {
error_type: SoraImageRemixErrorType;
error_message?: string;
}
export type SoraImageRemixResult = SoraImageRemixSuccess | SoraImageRemixError;
// Returns the Success and Error variants directly.
// Throws on Network/Tauri errors.
export const SoraImageRemix = async (request: SoraImageRemixRequest) : Promise<SoraImageRemixResult> => {