Compare commits

..
Author SHA1 Message Date
Brandon Thomas 88e8f99243 spz file uploads 2025-12-08 23:34:03 -05:00
19 changed files with 419 additions and 1038 deletions
@@ -40,6 +40,7 @@ pub struct MediaFileInsertBuilder {
origin_product_category: MediaFileOriginProductCategory, // NB: Non-nullable field
// maybe_origin_model_type: Option<MediaFileOriginModelType>,
// maybe_origin_model_token: Option<&'a ModelWeightToken>,
// maybe_origin_filename: Option<String>,
// Media info
maybe_mime_type: Option<String>,
@@ -50,10 +51,6 @@ pub struct MediaFileInsertBuilder {
maybe_frame_width: Option<u32>,
maybe_frame_height: Option<u32>,
checksum_sha2: Option<String>, // NB: Non-nullable field
// File name
maybe_title: Option<String>,
maybe_origin_filename: Option<String>,
// Media info for certain product areas
maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
@@ -103,8 +100,6 @@ impl MediaFileInsertBuilder {
maybe_frame_width: None,
maybe_frame_height: None,
checksum_sha2: None,
maybe_title: None,
maybe_origin_filename: None,
maybe_engine_category: None,
maybe_batch_generation_token: None,
public_bucket_directory_hash: None,
@@ -181,6 +176,7 @@ impl MediaFileInsertBuilder {
// TODO: Method for maybe_origin_model_type
// TODO: Method for maybe_origin_model_token
// TODO: Method for maybe_origin_filename
pub fn mime_type(mut self, mime_type: &str) -> Self {
self.maybe_mime_type = Some(mime_type.to_string());
@@ -230,16 +226,7 @@ impl MediaFileInsertBuilder {
self
}
pub fn maybe_title(mut self, maybe_title: Option<&str>) -> Self {
self.maybe_title = maybe_title.map(|title| title.to_string());
self
}
pub fn maybe_origin_filename(mut self, maybe_origin_filename: Option<&str>) -> Self {
self.maybe_origin_filename = maybe_origin_filename.map(|s| s.to_string());
self
}
// TODO: maybe_title
// TODO: maybe_text_transcript
// TODO: maybe_scene_source_media_file_token
// TODO: maybe_prompt_token
@@ -287,7 +274,7 @@ impl MediaFileInsertBuilder {
origin_product_category: self.origin_product_category,
maybe_origin_model_type: None, // TODO
maybe_origin_model_token: None, // TODO
maybe_origin_filename: self.maybe_origin_filename,
maybe_origin_filename: None, // TODO
maybe_mime_type: self.maybe_mime_type.as_deref(),
file_size_bytes: self.file_size_bytes,
maybe_duration_millis: self.maybe_duration_millis,
@@ -297,7 +284,7 @@ impl MediaFileInsertBuilder {
maybe_frame_height: self.maybe_frame_height,
maybe_engine_category: self.maybe_engine_category,
checksum_sha2: &checksum_sha2,
maybe_title: self.maybe_title.as_deref(),
maybe_title: None, // TODO
maybe_text_transcript: None, // TODO
maybe_scene_source_media_file_token: None, // TODO
maybe_prompt_token: self.maybe_prompt_token.as_ref(),
@@ -7,7 +7,6 @@ use log::{error, info, warn};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -16,7 +15,6 @@ use media::decode_basic_audio_info::decode_basic_audio_bytes_info;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use mimetypes::mimetype_to_extension::mimetype_to_extension;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use tokens::tokens::media_files::MediaFileToken;
@@ -358,23 +356,10 @@ pub async fn process_upload_media_file(
MediaFileType::Bvh => Some(".bvh".to_string()),
MediaFileType::Fbx => Some(".fbx".to_string()),
MediaFileType::Gltf => Some(".gltf".to_string()),
MediaFileType::Spz => Some(".spz".to_string()),
_ => None,
};
}
// For WorldLabs spz files.
let is_world_labs_spz= upload_media_request.file_name
.as_deref()
.map(|filename| filename.trim().to_ascii_lowercase())
.filter(|filename| filename.contains("ceramic"))
.filter(|filename| filename.ends_with(".spz"))
.is_some();
if is_world_labs_spz {
extension = Some(".ceramic.spz".to_string());
}
const PREFIX : Option<&str> = Some("upload_");
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, extension.as_deref());
@@ -391,61 +376,39 @@ pub async fn process_upload_media_file(
MediaFileUploadError::ServerError
})?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(maybe_user_token.as_ref())
.maybe_creator_anonymous_visitor(maybe_avt_token.as_ref())
.creator_ip_address(&ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(media_file_class)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Upload)
.creator_set_visibility(creator_set_visibility)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.checksum_sha2(&hash)
.maybe_title(upload_media_request.title.as_deref())
.maybe_origin_filename(upload_media_request.file_name.as_deref())
.is_intermediate_system_file(false)
.insert_pool(&server_state.mysql_pool)
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(media_file_class),
media_file_type,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
upload_type,
maybe_engine_category: None,
maybe_prompt_token: None,
maybe_batch_token: None,
maybe_animation_type: None,
maybe_mime_type: Some(mime_type),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None, // TODO: Fix this?
sha256_checksum: &hash,
maybe_title: upload_media_request.title.as_deref(),
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
public_bucket_directory_hash: public_upload_path.get_object_hash(),
maybe_public_bucket_prefix: PREFIX,
maybe_public_bucket_extension: extension.as_deref(),
pool: &server_state.mysql_pool,
})
.await
.map_err(|err| {
warn!("New generic download creation DB error: {:?}", err);
MediaFileUploadError::ServerError
})?;
//let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
// maybe_media_class: Some(media_file_class), // DONE
// media_file_type, // DONE
// maybe_creator_user_token: maybe_user_token.as_ref(), // DONE
// maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(), // DONE
// creator_ip_address: &ip_address, // DONE
// creator_set_visibility, // DONE
// upload_type, // DONE - weird type
// maybe_engine_category: None, // DONE
// maybe_prompt_token: None, // DONE
// maybe_batch_token: None, // DONE
// maybe_animation_type: None, // DONE
// maybe_mime_type: Some(mime_type), // DONE
// file_size_bytes: file_size_bytes as u64, // DONE
// maybe_duration_millis: None, // TODO: Fix this? // DONE
// sha256_checksum: &hash, // DONE
// maybe_title: upload_media_request.title.as_deref(), // DONE
// maybe_scene_source_media_file_token: None, // DONE
// is_intermediate_system_file: false, // NB: is_user_upload = TRUE // DONE
// public_bucket_directory_hash: public_upload_path.get_object_hash(), // DONE
// maybe_public_bucket_prefix: PREFIX, // DONE
// maybe_public_bucket_extension: extension.as_deref(), // DONE
// pool: &server_state.mysql_pool,
//})
// .await
// .map_err(|err| {
// warn!("New generic download creation DB error: {:?}", err);
// MediaFileUploadError::ServerError
// })?;
info!("new media file - token: {:?}", &media_token);
info!("new media file id: {} token: {:?}", record_id, &token);
Ok(SuccessCase::MediaSuccessfullyUploaded {
media_file_token: media_token,
media_file_token: token,
})
}
@@ -4,10 +4,10 @@ import {
faFilm,
faPalette,
faImage,
faPenNib,
faDroplet,
faPhotoFilm,
faGlobe,
faPencil,
} from "@fortawesome/pro-solid-svg-icons";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -47,7 +47,7 @@ export const APP_DESCRIPTORS: AppDescriptor[] = [
{
id: "EDIT",
label: "Edit Image",
icon: faPencil,
icon: faPenNib,
imageSrc: "/resources/gifs/INPAINT_CANVAS_DEMO.gif",
description: "Modify your image with AI",
large: true,
@@ -123,7 +123,7 @@ export const ALL_APPS: FullAppItem[] = [
id: "edit-image",
label: "Edit Image",
description: "Change with inpainting",
icon: faPencil,
icon: faPenNib,
category: "edit",
action: "EDIT",
color: "bg-purple-600/40",
@@ -283,11 +283,11 @@ const PageDraw = () => {
onAspectRatioChange={async (ratio: string) => {
const ratioToType = (ratio: string): AspectRatioType => {
switch (ratio) {
case "tall":
case "2:3":
return AspectRatioType.PORTRAIT;
case "wide":
case "3:2":
return AspectRatioType.LANDSCAPE;
case "square":
case "1:1":
return AspectRatioType.SQUARE;
default:
return AspectRatioType.NONE;
@@ -1,4 +1,4 @@
import { faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
import { faImage, faImages } from "@fortawesome/pro-solid-svg-icons";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
@@ -153,7 +153,7 @@ export const BaseImageSelector = ({
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
<div className="aspect-video w-full max-w-5xl bg-ui-background">
<UploadEntryCard
icon={faPencil}
icon={faImage}
title="Edit Image"
description="Click to upload or drag and drop an image here to edit"
accentBackgroundClass="bg-blue-500/40"
@@ -1,13 +1,6 @@
import { useState, useRef, useEffect, useCallback } from "react";
import Konva from "konva"; // just for types
import {
EnqueueEditImage,
EnqueueImageInpaint,
EnqueueEditImageSize,
EnqueueEditImageResolution,
} from "@storyteller/tauri-api";
import { PromptsApi } from "@storyteller/api";
import { RefImage, usePromptEditStore } from "@storyteller/ui-promptbox";
import { EnqueueEditImage, EnqueueImageInpaint } from "@storyteller/tauri-api";
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
import { useCopyPasteHotkeys } from "../PageDraw/hooks/useCopyPasteHotkeys";
import { useDeleteHotkeys } from "../PageDraw/hooks/useDeleteHotkeys";
@@ -65,7 +58,6 @@ const PageEdit = () => {
const historyImageBundles = useEditStore(
(state) => state.historyImageBundles,
);
const referenceImages = usePromptEditStore((s) => s.referenceImages);
// Pass store actions directly as callbacks
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
@@ -154,16 +146,17 @@ const PageEdit = () => {
// Top toolbar (MarkerToolControlBar): ~140px from top (toolbar + padding)
// Bottom prompt box: ~200px from bottom (can vary with reference images)
// Mode selector above prompt: ~60px
const hasTopToolbar =
const topToolbarHeight =
store.activeTool === "marker" ||
store.activeTool === "eraser" ||
store.activeTool === "edit";
const topToolbarHeight = hasTopToolbar ? 140 : 80;
store.activeTool === "edit"
? 140
: 0;
// Check if model supports masking or is nano banana (has mode selector)
const hasModeSelectorUI =
selectedImageModel?.usesInpaintingMask ||
selectedImageModel?.isNanoBananaModel();
selectedImageModel?.id === "gemini_25_flash";
const bottomUIHeight = hasModeSelectorUI ? 260 : 200;
const totalVerticalReserved = topToolbarHeight + bottomUIHeight;
@@ -276,54 +269,8 @@ const PageEdit = () => {
return new Uint8Array(arrayBuffer);
};
const getCompositeCanvasFile = async (): Promise<File | null> => {
if (
!stageRef.current ||
!leftPanelRef.current ||
!baseImageKonvaRef.current ||
!store.baseImageBitmap
) {
return null;
}
const rect = baseImageKonvaRef.current;
const width = rect.width();
const height = rect.height();
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
const markerLayerCanvas = leftPanelRef.current.toCanvas({
x: stageRef.current.x(),
y: stageRef.current.y(),
width: rect.width() * stageRef.current.scaleX(),
height: rect.height() * stageRef.current.scaleY(),
pixelRatio: 1 / stageRef.current.scaleX(),
});
const fittedMarkerCanvas = normalizeCanvas(
markerLayerCanvas,
width,
height,
);
ctx.drawImage(fittedMarkerCanvas, 0, 0, width, height);
const blob = await canvas.convertToBlob({ type: "image/png" });
const uuid = crypto.randomUUID();
return new File([blob], `${uuid}.png`, { type: "image/png" });
};
const handleGenerate = useCallback(
async (
prompt: string,
options?: {
aspectRatio?: string;
resolution?: string;
images?: RefImage[];
},
) => {
async (prompt: string) => {
const editedImageToken = store.baseImageInfo?.mediaToken;
if (!editedImageToken) {
@@ -331,39 +278,7 @@ const PageEdit = () => {
return;
}
// Helper to map aspect ratio string to enum
const mapAspectRatio = (
ratio?: string,
): EnqueueEditImageSize | undefined => {
switch (ratio) {
case "auto":
return EnqueueEditImageSize.Auto;
case "wide":
return EnqueueEditImageSize.Wide;
case "tall":
return EnqueueEditImageSize.Tall;
case "square":
return EnqueueEditImageSize.Square;
default:
return undefined;
}
};
// Helper to map resolution string to enum
const mapResolution = (
res?: string,
): EnqueueEditImageResolution | undefined => {
switch (res) {
case "1k":
return EnqueueEditImageResolution.OneK;
case "2k":
return EnqueueEditImageResolution.TwoK;
case "4k":
return EnqueueEditImageResolution.FourK;
default:
return undefined;
}
};
const arrayBuffer = await getMaskArrayBuffer();
const subscriberId: string =
crypto?.randomUUID?.() ??
@@ -373,7 +288,6 @@ const PageEdit = () => {
let result;
if (selectedImageModel?.editingIsInpainting) {
const arrayBuffer = await getMaskArrayBuffer();
result = await EnqueueImageInpaint({
model: selectedImageModel,
image_media_token: editedImageToken,
@@ -383,51 +297,17 @@ const PageEdit = () => {
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
});
} else if (selectedImageModel?.isNanoBananaModel()) {
const compositeFile = await getCompositeCanvasFile();
if (!compositeFile) {
console.error("Failed to create composite canvas");
return;
}
const api = new PromptsApi();
const snapshotResult = await api.uploadSceneSnapshot({
screenshot: compositeFile,
});
if (!snapshotResult.data) {
console.error("Failed to upload scene snapshot");
return;
}
const imgs = options?.images || [];
result = await EnqueueEditImage({
model: selectedImageModel,
scene_image_media_token: snapshotResult.data,
image_media_tokens: imgs
.map((img) => img.mediaToken)
.filter((t) => t.length > 0),
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
aspect_ratio: mapAspectRatio(options?.aspectRatio),
image_resolution: mapResolution(options?.resolution),
});
} else {
const imgs = options?.images || [];
result = await EnqueueEditImage({
model: selectedImageModel,
image_media_tokens: [
editedImageToken,
...imgs
.filter((img) => img.mediaToken !== editedImageToken)
.map((img) => img.mediaToken),
].filter((t) => t.length > 0),
disable_system_prompt: true,
image_media_tokens: [editedImageToken],
disable_system_prompt: true, // No meaning yet
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
aspect_ratio: mapAspectRatio(options?.aspectRatio),
image_resolution: mapResolution(options?.resolution),
//scene_image_media_token,
//aspect_ratio: aspectRatio,
});
}
@@ -444,13 +324,7 @@ const PageEdit = () => {
throw error;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
generationCount,
selectedImageModel,
store.baseImageInfo?.mediaToken,
referenceImages,
],
[generationCount, selectedImageModel, store.baseImageInfo?.mediaToken],
);
const isNanoBananaModel = selectedImageModel?.isNanoBananaModel();
@@ -1,11 +1,10 @@
import React from "react";
import { PromptBoxEdit, PromptBoxEditProps } from "@storyteller/ui-promptbox";
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
const PromptEditor: React.FC<PromptBoxEditProps> = (props) => {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col space-y-2">
<PromptBoxEdit {...props} uploadImage={uploadImage} />
<PromptBoxEdit {...props} />
</div>
);
};
@@ -1,6 +1,6 @@
import * as THREE from "three";
import { TransformControls } from "three/examples/jsm/Addons";
import { FKBoneBlacklistStrings } from "./FKBoneFilter";
import { TransformControls } from "../TransformControls";
export const FKHelperSphereName = "__FKHelperSphere__";
@@ -59,14 +59,14 @@ interface LightboxModalProps {
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
media_id: string | undefined,
media_id: string | undefined
) => Promise<void>;
mediaClass?: string;
onPromptCopy?: (prompt: string) => void;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
onTurnIntoVideoClicked?: (
url: string,
media_id?: string,
media_id?: string
) => Promise<void> | void;
batchImageToken?: string;
}
@@ -104,7 +104,7 @@ export function LightboxModal({
const [hasPromptToken, setHasPromptToken] = useState<boolean>(false);
const [isPromptHovered, setIsPromptHovered] = useState<boolean>(false);
const [generationProvider, setGenerationProvider] = useState<string | null>(
null,
null
);
const [modelType, setModelType] = useState<string | null>(null);
const [contextImages, setContextImages] = useState<Array<{
@@ -121,7 +121,7 @@ export function LightboxModal({
const shareCopiedTimeoutRef = useRef<number | null>(null);
const [currentMediaId, setCurrentMediaId] = useState<string | undefined>(
mediaId,
mediaId
);
useEffect(() => {
setCurrentMediaId(mediaId);
@@ -236,7 +236,7 @@ export function LightboxModal({
}))
.filter(
(item): item is { url: string; token: string } =>
Boolean(item.url) && Boolean(item.token),
Boolean(item.url) && Boolean(item.token)
);
if (items.length > 0) {
@@ -293,7 +293,7 @@ export function LightboxModal({
if (!emblaMainApi || !emblaThumbsApi) return;
emblaMainApi.scrollTo(index);
},
[emblaMainApi, emblaThumbsApi],
[emblaMainApi, emblaThumbsApi]
);
const onSelect = useCallback(() => {
@@ -427,9 +427,7 @@ export function LightboxModal({
PLACEHOLDER_IMAGES.DEFAULT;
e.currentTarget.style.opacity = "0.3";
// Set the `data-brokenurl` property for debugging the broken images:
(
e.currentTarget as HTMLImageElement
).dataset.brokenurl = url || "";
(e.currentTarget as HTMLImageElement).dataset.brokenurl = url || "";
}
}}
onLoad={() => {
@@ -462,7 +460,7 @@ export function LightboxModal({
"embla-thumbs__slide relative h-20 w-20 flex-[0_0_5rem] overflow-hidden rounded-md border-2 transition-all",
isSelected
? "border-brand-primary-400 opacity-100"
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100",
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100"
)}
>
<img
@@ -480,13 +478,11 @@ export function LightboxModal({
</div>
)}
{!mediaLoaded &&
selectedImageUrl &&
mediaClass !== "dimensional" && (
<div className="absolute inset-0 bg-ui-panel flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-base-fg" />
</div>
)}
{!mediaLoaded && selectedImageUrl && mediaClass !== "dimensional" && (
<div className="absolute inset-0 bg-ui-panel flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-base-fg" />
</div>
)}
</div>
{/* info + actions */}
@@ -516,7 +512,7 @@ export function LightboxModal({
</div>
<div
className={twMerge(
"relative text-sm text-base-fg break-words p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed",
"relative text-sm text-base-fg break-words p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed"
)}
style={{
background: isPromptHovered
@@ -551,7 +547,7 @@ export function LightboxModal({
<div
className={twMerge(
"pointer-events-none absolute inset-0 flex items-end justify-end opacity-0 transition-opacity duration-50",
isPromptHovered && "opacity-100",
isPromptHovered && "opacity-100"
)}
>
<div
@@ -572,7 +568,7 @@ export function LightboxModal({
<div className="text-sm font-medium text-base-fg/90">
Reference Images
</div>
<div className="grid grid-cols-6 gap-2 w-fit">
<div className="grid grid-cols-6 gap-2">
{contextImages.map((contextImage, index) => {
const { thumbnail, fullSize } =
getContextImageThumbnail(contextImage, {
@@ -582,7 +578,7 @@ export function LightboxModal({
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg w-12 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer hover:cursor-zoom-in"
className="glass relative aspect-square overflow-hidden rounded-lg w-14 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer hover:cursor-zoom-in"
onClick={() => setRefPreviewUrl(fullSize)}
>
<img
@@ -635,7 +631,7 @@ export function LightboxModal({
<div className="flex items-center gap-2">
{getProviderIconByName(
generationProvider,
"h-4 w-4 invert",
"h-4 w-4 invert"
)}
<span className="text-sm text-base-fg rounded">
{getProviderDisplayName(generationProvider)}
@@ -685,7 +681,7 @@ export function LightboxModal({
setShareCopied(true);
if (shareCopiedTimeoutRef.current) {
window.clearTimeout(
shareCopiedTimeoutRef.current,
shareCopiedTimeoutRef.current
);
}
shareCopiedTimeoutRef.current = window.setTimeout(
@@ -693,7 +689,7 @@ export function LightboxModal({
setShareCopied(false);
shareCopiedTimeoutRef.current = null;
},
1500,
1500
);
} catch (err) {
toast.error("Unable to copy link");
@@ -716,7 +712,7 @@ export function LightboxModal({
gtagEvent("edit_image_clicked");
await onEditClicked(
actionUrl,
selectedMediaToken,
selectedMediaToken
);
}}
>
@@ -735,7 +731,7 @@ export function LightboxModal({
gtagEvent("turn_into_video_clicked");
await onTurnIntoVideoClicked(
actionUrl,
selectedMediaToken,
selectedMediaToken
);
}}
>
@@ -752,7 +748,7 @@ export function LightboxModal({
gtagEvent("add_to_scene_clicked");
await onAddToSceneClicked(
actionUrl,
selectedMediaToken,
selectedMediaToken
);
onClose();
onCloseGallery();
@@ -17,7 +17,6 @@ import {
faSpinnerThird,
faFrame,
faCopy,
faExpand,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleVertical,
@@ -31,7 +30,6 @@ import { PromptsApi } from "@storyteller/api";
import {
EnqueueEditImage,
EnqueueEditImageSize,
EnqueueEditImageResolution,
} from "@storyteller/tauri-api";
import { usePrompt2DStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
@@ -40,7 +38,7 @@ import { ImageModel } from "@storyteller/model-list";
import { ImagePromptRow } from "./ImagePromptRow";
import { twMerge } from "tailwind-merge";
export type AspectRatio = "wide" | "tall" | "square";
export type AspectRatio = "1:1" | "3:2" | "2:3";
interface PromptBox2DProps {
uploadImage: ({
@@ -84,8 +82,6 @@ export const PromptBox2D = ({
const setUseSystemPrompt = usePrompt2DStore((s) => s.setUseSystemPrompt);
const aspectRatio = usePrompt2DStore((s) => s.aspectRatio);
const setAspectRatio = usePrompt2DStore((s) => s.setAspectRatio);
const resolution = usePrompt2DStore((s) => s.resolution);
const setResolution = usePrompt2DStore((s) => s.setResolution);
const generationCount = usePrompt2DStore((s) => s.generationCount);
const setGenerationCount = usePrompt2DStore((s) => s.setGenerationCount);
@@ -95,42 +91,24 @@ export const PromptBox2D = ({
const [showImagePrompts, setShowImagePrompts] = useState(false);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "Wide",
selected: aspectRatio === "wide",
label: "3:2",
selected: aspectRatio === "3:2",
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
},
{
label: "Tall",
selected: aspectRatio === "tall",
label: "2:3",
selected: aspectRatio === "2:3",
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
},
{
label: "Square",
selected: aspectRatio === "square",
label: "1:1",
selected: aspectRatio === "1:1",
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
},
]);
const [resolutionList, setResolutionList] = useState<PopoverItem[]>([
{
label: "1K",
selected: resolution === "1k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "2K",
selected: resolution === "2k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "4K",
selected: resolution === "4k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
]);
const [generationCountList, setGenerationCountList] = useState<PopoverItem[]>(
[],
[]
);
// Update generation count options from selected model capabilities
@@ -138,7 +116,7 @@ export const PromptBox2D = ({
const caps = getCapabilitiesForModel(selectedImageModel);
const items: PopoverItem[] = Array.from(
{ length: caps.maxGenerationCount },
(_, i) => i + 1,
(_, i) => i + 1
).map((count) => ({
label: String(count),
selected: count === generationCount,
@@ -148,7 +126,7 @@ export const PromptBox2D = ({
// Clamp selection to allowed range
if (generationCount < 1 || generationCount > caps.maxGenerationCount) {
setGenerationCount(
Math.min(Math.max(1, generationCount), caps.maxGenerationCount),
Math.min(Math.max(1, generationCount), caps.maxGenerationCount)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -160,19 +138,10 @@ export const PromptBox2D = ({
prev.map((item) => ({
...item,
selected: item.label === String(generationCount),
})),
}))
);
}, [generationCount]);
useEffect(() => {
setResolutionList((prev) =>
prev.map((item) => ({
...item,
selected: item.label.toLowerCase() === resolution,
})),
);
}, [resolution]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isImageRowVisible =
showImagePrompts ||
@@ -187,21 +156,16 @@ export const PromptBox2D = ({
});
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
const value = selectedItem.label.toLowerCase() as AspectRatio;
onAspectRatioChange?.(value);
setAspectRatio(value);
onAspectRatioChange?.(selectedItem.label as AspectRatio);
setAspectRatio(selectedItem.label as AspectRatio);
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
}))
);
};
const handleResolutionSelect = (selectedItem: PopoverItem) => {
setResolution(selectedItem.label.toLowerCase() as any);
};
// Image prompt row replaces legacy upload/gallery UI
const handleGenerationCountSelect = (selectedItem: PopoverItem) => {
@@ -211,7 +175,7 @@ export const PromptBox2D = ({
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
}))
);
};
@@ -270,19 +234,15 @@ export const PromptBox2D = ({
console.log("Snapshot media token:", snapshotMediaToken.data);
const aspectRatio = getCurrentAspectRatio();
const resolution = getCurrentResolution();
const generateResponse = await EnqueueEditImage({
model: selectedImageModel,
scene_image_media_token: snapshotMediaToken.data!,
image_media_tokens: referenceImages
.map((image) => image.mediaToken)
.filter((t) => t.length > 0),
image_media_tokens: referenceImages.map((image) => image.mediaToken),
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
image_count: generationCount,
aspect_ratio: aspectRatio,
image_resolution: resolution,
});
console.log("generateResponse", generateResponse);
@@ -296,7 +256,7 @@ export const PromptBox2D = ({
let image = getCanvasRenderBitmap();
if (image === undefined) {
toast.error(
"Error: Unable to generate image. Please check the input and try again.",
"Error: Unable to generate image. Please check the input and try again."
);
return;
}
@@ -330,9 +290,7 @@ export const PromptBox2D = ({
disableSystemPrompt: !useSystemPrompt,
prompt: prompt,
snapshotMediaToken: snapshotMediaToken.data,
additionalImages: referenceImages
.map((image) => image.mediaToken)
.filter((t) => t.length > 0),
additionalImages: referenceImages.map((image) => image.mediaToken),
});
if (response.success === true) {
@@ -367,7 +325,7 @@ export const PromptBox2D = ({
"(4) Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages,
referenceImages
);
const isDesktop = IsDesktopApp();
@@ -381,7 +339,7 @@ export const PromptBox2D = ({
} catch (error) {
console.error("Error during image generation:", error);
toast.error(
"An error occurred while generating the image. Please try again.",
"An error occurred while generating the image. Please try again."
);
} finally {
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -398,31 +356,17 @@ export const PromptBox2D = ({
const getCurrentAspectRatio = (): EnqueueEditImageSize => {
const selected = aspectRatioList.find((item) => item.selected);
switch (selected?.label.toLowerCase()) {
case "wide":
switch (selected?.label) {
case "3:2":
return EnqueueEditImageSize.Wide;
case "tall":
case "2:3":
return EnqueueEditImageSize.Tall;
case "square":
case "1:1":
default:
return EnqueueEditImageSize.Square;
}
};
const getCurrentResolution = (): EnqueueEditImageResolution | undefined => {
const selected = resolutionList.find((item) => item.selected);
switch (selected?.label) {
case "1k":
return EnqueueEditImageResolution.OneK;
case "2k":
return EnqueueEditImageResolution.TwoK;
case "4k":
return EnqueueEditImageResolution.FourK;
default:
return undefined;
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Stop propagation of keyboard events to prevent them from reaching the canvas
e.stopPropagation();
@@ -450,7 +394,7 @@ export const PromptBox2D = ({
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1,
selectedImageModel?.maxImagePromptCount ?? 1
)}
allowUpload={true}
referenceImages={referenceImages}
@@ -462,7 +406,7 @@ export const PromptBox2D = ({
src={image.url}
alt="Reference preview"
className="w-full h-full object-contain"
/>,
/>
);
setIsModalOpen(true);
}}
@@ -473,7 +417,7 @@ export const PromptBox2D = ({
"glass w-[730px] rounded-xl p-4",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
"rounded-t-none"
)}
>
<div className="flex justify-center gap-2">
@@ -488,7 +432,7 @@ export const PromptBox2D = ({
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary",
isImageRowVisible && "text-primary"
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
@@ -525,47 +469,26 @@ export const PromptBox2D = ({
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{selectedImageModel?.canChangeAspectRatio && (
<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>
)}
{selectedImageModel?.canChangeResolution && (
<Tooltip
content="Resolution"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={resolutionList}
onSelect={handleResolutionSelect}
mode="toggle"
panelTitle="Resolution"
showIconsInList
triggerIcon={
<FontAwesomeIcon icon={faExpand} className="h-4 w-4" />
}
/>
</Tooltip>
)}
<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={
useSystemPrompt
@@ -9,7 +9,6 @@ import {
faSpinnerThird,
faMessageCheck,
faMessageXmark,
faExpand,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleWide,
@@ -41,7 +40,6 @@ import {
CommandSuccessStatus,
EnqueueEditImage,
EnqueueEditImageSize,
EnqueueEditImageResolution,
} from "@storyteller/tauri-api";
import { usePrompt3DStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
@@ -106,46 +104,27 @@ export const PromptBox3D = ({
const setPrompt = usePrompt3DStore((s) => s.setPrompt);
const useSystemPrompt = usePrompt3DStore((s) => s.useSystemPrompt);
const setUseSystemPrompt = usePrompt3DStore((s) => s.setUseSystemPrompt);
const resolution = usePrompt3DStore((s) => s.resolution);
const setResolution = usePrompt3DStore((s) => s.setResolution);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const referenceImages = usePrompt3DStore((s) => s.referenceImages);
const setReferenceImages = usePrompt3DStore((s) => s.setReferenceImages);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "Wide",
label: "3:2",
selected: true,
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
},
{
label: "Tall",
label: "2:3",
selected: false,
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
},
{
label: "Square",
label: "1:1",
selected: false,
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
},
]);
const [resolutionList, setResolutionList] = useState<PopoverItem[]>([
{
label: "1K",
selected: resolution === "1k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "2K",
selected: resolution === "2k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "4K",
selected: resolution === "4k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
]);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const isImageRowVisible =
showImagePrompts ||
@@ -159,26 +138,19 @@ export const PromptBox3D = ({
}
}, [prompt]);
useEffect(() => {
setResolutionList((prev) =>
prev.map((item) => ({
...item,
selected: item.label.toLowerCase() === resolution,
}))
);
}, [resolution]);
// Update aspect ratio list based on the current cameraAspectRatio signal
useEffect(() => {
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected:
(item.label === "Wide" &&
(item.label === "16:9" &&
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_16_9) ||
(item.label === "3:2" &&
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_3_2) ||
(item.label === "Tall" &&
(item.label === "2:3" &&
cameraAspectRatio.value === CameraAspectRatio.VERTICAL_2_3) ||
(item.label === "Square" &&
(item.label === "1:1" &&
cameraAspectRatio.value === CameraAspectRatio.SQUARE_1_1),
}))
);
@@ -195,27 +167,26 @@ export const PromptBox3D = ({
// Map the selected label to the corresponding CameraAspectRatio enum value
let newRatio: CameraAspectRatio;
switch (selectedItem.label.toLowerCase()) {
case "wide":
switch (selectedItem.label) {
case "16:9":
newRatio = CameraAspectRatio.HORIZONTAL_16_9;
break;
case "3:2":
newRatio = CameraAspectRatio.HORIZONTAL_3_2;
break;
case "tall":
case "2:3":
newRatio = CameraAspectRatio.VERTICAL_2_3;
break;
case "square":
case "1:1":
newRatio = CameraAspectRatio.SQUARE_1_1;
break;
default:
newRatio = CameraAspectRatio.HORIZONTAL_3_2;
newRatio = CameraAspectRatio.HORIZONTAL_16_9;
}
onAspectRatioSelect(newRatio);
};
const handleResolutionSelect = (selectedItem: PopoverItem) => {
setResolution(selectedItem.label.toLowerCase() as any);
};
// ImagePromptRow will handle uploads and gallery internally
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
@@ -364,7 +335,6 @@ export const PromptBox3D = ({
console.log("snapshotResult", snapshotResult);
const aspectRatio = getCurrentAspectRatio();
const resolution = getCurrentResolution();
const generateResponse = await EnqueueEditImage({
model: selectedImageModel,
@@ -374,7 +344,6 @@ export const PromptBox3D = ({
prompt: prompt,
image_count: 1,
aspect_ratio: aspectRatio,
image_resolution: resolution,
});
console.log("generateResponse", generateResponse);
@@ -444,20 +413,6 @@ export const PromptBox3D = ({
}
};
const getCurrentResolution = (): EnqueueEditImageResolution | undefined => {
const selected = resolutionList.find((item) => item.selected);
switch (selected?.label) {
case "1k":
return EnqueueEditImageResolution.OneK;
case "2k":
return EnqueueEditImageResolution.TwoK;
case "4k":
return EnqueueEditImageResolution.FourK;
default:
return undefined;
}
};
const handleSaveFrame = async () => {
if (!snapshotCurrentFrame) {
return;
@@ -584,47 +539,26 @@ export const PromptBox3D = ({
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{selectedImageModel?.canChangeAspectRatio && (
<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>
)}
{selectedImageModel?.canChangeResolution && (
<Tooltip
content="Resolution"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={resolutionList}
onSelect={handleResolutionSelect}
mode="toggle"
panelTitle="Resolution"
showIconsInList
triggerIcon={
<FontAwesomeIcon icon={faExpand} className="h-4 w-4" />
}
/>
</Tooltip>
)}
<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"
@@ -1,10 +1,6 @@
import {
faRectangle,
faRectangleVertical,
faSquare,
} from "@fortawesome/pro-regular-svg-icons";
import {
faEdit,
// faExpand,
faMessageCheck,
faMessageXmark,
faMousePointer,
@@ -16,8 +12,6 @@ import {
faEraser,
faUndo,
faRedo,
faExpand,
faArrowsUpDownLeftRight,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ToggleButton } from "@storyteller/ui-button";
@@ -28,19 +22,12 @@ import { useEffect, useRef, useState } from "react";
import { ImageModel, getCapabilitiesForModel } from "@storyteller/model-list";
import { twMerge } from "tailwind-merge";
import { ImagePromptRow, type UploadImageFn } from "./ImagePromptRow";
import { RefImage, usePromptEditStore } from "./promptStore";
import { usePromptEditStore } from "./promptStore";
export interface PromptBoxEditProps {
onModeChange?: (mode: string) => void;
selectedMode?: string;
onGenerateClick: (
prompt: string,
options?: {
aspectRatio?: string;
resolution?: string;
images?: RefImage[];
},
) => void | Promise<void>;
onGenerateClick: (prompt: string) => void | Promise<void>;
isDisabled?: boolean;
onFitPressed?: () => void | Promise<void>;
selectedImageModel?: ImageModel;
@@ -74,102 +61,17 @@ export const PromptBoxEdit = ({
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [isFocused, setIsFocused] = useState(false);
const [generationCount, setGenerationCount] = useState<number>(
typeof generationCountProp === "number" ? generationCountProp : 1,
typeof generationCountProp === "number" ? generationCountProp : 1
);
const [internalEnqueueing, setInternalEnqueueing] = useState(false);
const referenceImages = usePromptEditStore((s) => s.referenceImages);
const setReferenceImages = usePromptEditStore((s) => s.setReferenceImages);
const aspectRatio = usePromptEditStore((s) => s.aspectRatio);
const setAspectRatio = usePromptEditStore((s) => s.setAspectRatio);
const resolution = usePromptEditStore((s) => s.resolution);
const setResolution = usePromptEditStore((s) => s.setResolution);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const [generationCountList, setGenerationCountList] = useState<PopoverItem[]>(
[],
[]
);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "Auto",
selected: aspectRatio === "auto",
icon: (
<FontAwesomeIcon icon={faArrowsUpDownLeftRight} className="h-4 w-4" />
),
},
{
label: "Wide",
selected: aspectRatio === "wide",
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
},
{
label: "Tall",
selected: aspectRatio === "tall",
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
},
{
label: "Square",
selected: aspectRatio === "square",
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
},
]);
const [resolutionList, setResolutionList] = useState<PopoverItem[]>([
{
label: "1K",
selected: resolution === "1k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "2K",
selected: resolution === "2k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "4K",
selected: resolution === "4k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
]);
useEffect(() => {
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected:
item.label.toLowerCase() === aspectRatio ||
(item.label === "Auto" && aspectRatio === "auto"),
})),
);
}, [aspectRatio]);
useEffect(() => {
setResolutionList((prev) =>
prev.map((item) => ({
...item,
selected: item.label.toLowerCase() === resolution,
})),
);
}, [resolution]);
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
const value =
selectedItem.label === "Auto" ? "auto" : selectedItem.label.toLowerCase();
setAspectRatio(value as any);
};
const handleResolutionSelect = (selectedItem: PopoverItem) => {
setResolution(selectedItem.label.toLowerCase() as any);
};
const getCurrentAspectRatioIcon = () => {
const selected = aspectRatioList.find((item) => item.selected);
if (!selected || !selected.icon) return faRectangle;
const iconElement = selected.icon as React.ReactElement<{ icon: any }>;
return iconElement.props.icon;
};
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isImageRowVisible =
(selectedImageModel?.canUseImagePrompt &&
@@ -183,17 +85,20 @@ export const PromptBoxEdit = ({
}
});
// Sync internal state if a controlled prop is provided
useEffect(() => {
if (typeof generationCountProp === "number") {
setGenerationCount(generationCountProp);
}
}, [generationCountProp]);
// Build generation count options based on selected model
useEffect(() => {
const caps = getCapabilitiesForModel(selectedImageModel);
console.log(">>> caps", caps);
const items: PopoverItem[] = Array.from(
{ length: caps.maxGenerationCount },
(_, i) => i + 1,
(_, i) => i + 1
).map((count) => ({
label: String(count),
selected: count === generationCount,
@@ -201,10 +106,11 @@ export const PromptBoxEdit = ({
}));
setGenerationCountList(items);
// Clamp selection
if (generationCount < 1 || generationCount > caps.maxGenerationCount) {
const clamped = Math.min(
Math.max(1, generationCount),
caps.maxGenerationCount,
caps.maxGenerationCount
);
setGenerationCount(clamped);
onGenerationCountChange?.(clamped);
@@ -212,12 +118,13 @@ export const PromptBoxEdit = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedImageModel]);
// Keep UI selection in sync when store/value changes
useEffect(() => {
setGenerationCountList((prev) =>
prev.map((item) => ({
...item,
selected: item.label === String(generationCount),
})),
}))
);
}, [generationCount]);
@@ -230,6 +137,7 @@ export const PromptBoxEdit = ({
const next =
value.slice(0, selectionStart) + pastedText + value.slice(selectionEnd);
setPrompt(next);
// Restore caret after React updates the value
requestAnimationFrame(() => {
const pos = selectionStart + pastedText.length;
textareaRef.current?.setSelectionRange(pos, pos);
@@ -242,10 +150,12 @@ export const PromptBoxEdit = ({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Stop propagation of keyboard events to prevent them from reaching the canvas
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// Block Enter if requirements are not met
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
if (!prompt.trim() || isDisabled || busy) return;
void handleGenerate();
@@ -260,7 +170,7 @@ export const PromptBoxEdit = ({
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
}))
);
};
@@ -272,13 +182,7 @@ export const PromptBoxEdit = ({
setInternalEnqueueing(false);
}, 10000);
try {
await Promise.resolve(
onGenerateClick(prompt, {
aspectRatio,
resolution,
images: referenceImages,
}),
);
await Promise.resolve(onGenerateClick(prompt));
} finally {
clearTimeout(timeout);
setInternalEnqueueing(false);
@@ -299,43 +203,50 @@ export const PromptBoxEdit = ({
},
]
: supportsMaskedInpainting
? [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "eraser",
icon: faEraser,
text: "Eraser",
},
]
: [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "select",
icon: faMousePointer,
text: "Select",
},
];
? [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "eraser",
icon: faEraser,
text: "Eraser",
},
]
: [
{
value: "edit",
icon: faEdit,
text: "Edit Region",
},
{
value: "select",
icon: faMousePointer,
text: "Select",
},
];
return (
<>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(supportsMaskedInpainting || isNanoBananaModel) && (
<div
className={twMerge(
"glass w-fit mx-auto rounded-xl px-2 py-2 flex items-center gap-3",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"mb-[72px]",
{selectedImageModel?.canUseImagePrompt && isImageRowVisible && (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1
)}
>
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage}
onImageClick={() => {}}
/>
)}
{(supportsMaskedInpainting || isNanoBananaModel) && (
<div className="glass w-fit mx-auto rounded-xl px-2 py-2 flex items-center gap-3">
<ButtonIconSelect
options={modes}
onOptionChange={onModeSelectionChange}
@@ -364,199 +275,140 @@ export const PromptBoxEdit = ({
</div>
</div>
)}
<div className="relative z-20 flex flex-col">
{selectedImageModel?.canUseImagePrompt && isImageRowVisible && (
<ImagePromptRow
visible={true}
maxImagePromptCount={Math.max(
1,
selectedImageModel?.maxImagePromptCount ?? 1,
)}
allowUpload={true}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
uploadImage={uploadImage}
onImageClick={() => {}}
className=""
/>
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
isFocused && "ring-1 ring-primary border-primary",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none"
)}
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
isFocused && "ring-1 ring-primary border-primary",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
)}
>
<div className="flex justify-center gap-2">
{selectedImageModel?.canUseImagePrompt && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<div className="flex justify-center gap-2">
{selectedImageModel?.canUseImagePrompt && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<Button
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary"
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<Button
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary",
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="group-hover:opacity-100 opacity-80 transition-all"
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="group-hover:opacity-100 opacity-80 transition-all"
>
<path
opacity="1"
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</Button>
</Tooltip>
)}
<textarea
ref={textareaRef}
rows={1}
placeholder="Write what you want to change in your image and click generate..."
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={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<path
opacity="1"
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</Button>
</Tooltip>
)}
<textarea
ref={textareaRef}
rows={1}
placeholder="Write what you want to change in your image and click generate..."
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={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<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>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{selectedImageModel?.canChangeAspectRatio && (
<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>
)}
{selectedImageModel?.canChangeResolution && (
<Tooltip
content="Resolution"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={resolutionList}
onSelect={handleResolutionSelect}
mode="toggle"
panelTitle="Resolution"
showIconsInList
triggerIcon={
<FontAwesomeIcon icon={faExpand} className="h-4 w-4" />
}
/>
</Tooltip>
)}
<div className="flex items-center gap-2">
{onFitPressed && (
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
content={"Fit canvas to screen"}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{onFitPressed && (
<Tooltip
content={"Fit canvas to screen"}
position="top"
className="z-50"
delay={200}
<Button
variant="secondary"
className="h-9 bg-ui-controls/60 px-3 text-base-fg hover:bg-ui-controls/90"
onClick={onFitPressed}
>
<Button
variant="secondary"
className="h-9 bg-ui-controls/60 px-3 text-base-fg hover:bg-ui-controls/90"
onClick={onFitPressed}
>
<FontAwesomeIcon icon={faFrame} className="h-4 w-4" />
Fit
</Button>
</Tooltip>
)}
<Tooltip
content="Number of generations"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={generationCountList}
onSelect={handleGenerationCountSelect}
mode="toggle"
panelTitle="No. of images"
triggerIcon={
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
}
panelClassName="min-w-28"
buttonClassName="h-9"
/>
<FontAwesomeIcon icon={faFrame} className="h-4 w-4" />
Fit
</Button>
</Tooltip>
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={
!(isEnqueueing ?? internalEnqueueing) && !isDisabled
? faSparkles
: undefined
)}
<Tooltip
content="Number of generations"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={generationCountList}
onSelect={handleGenerationCountSelect}
mode="toggle"
triggerIcon={
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
}
onClick={handleGenerate}
disabled={
(isEnqueueing ?? internalEnqueueing) ||
isDisabled ||
!prompt.trim()
}
>
{(isEnqueueing ?? internalEnqueueing) ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
panelClassName="min-w-28"
panelTitle="No. of images"
buttonClassName="h-9"
/>
</Tooltip>
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={
!(isEnqueueing ?? internalEnqueueing) && !isDisabled
? faSparkles
: undefined
}
onClick={handleGenerate}
disabled={
(isEnqueueing ?? internalEnqueueing) ||
isDisabled ||
!prompt.trim()
}
>
{isEnqueueing ?? internalEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
@@ -10,7 +10,6 @@ import {
EnqueueTextToImage,
EnqueueTextToImageRequest,
EnqueueTextToImageSize,
EnqueueTextToImageResolution,
} from "@storyteller/tauri-api";
import {
faMessageXmark,
@@ -18,7 +17,6 @@ import {
faSparkles,
faSpinnerThird,
faCopy,
faExpand,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangle,
@@ -47,7 +45,7 @@ interface PromptBoxImageProps {
onEnqueuePressed?: (
prompt: string,
count: number,
subscriberId: string,
subscriberId: string
) => void | Promise<void>;
selectedModel?: ImageModel;
imageMediaId?: string;
@@ -89,8 +87,6 @@ export const PromptBoxImage = ({
const setUseSystemPrompt = usePromptImageStore((s) => s.setUseSystemPrompt);
const aspectRatio = usePromptImageStore((s) => s.aspectRatio);
const setAspectRatio = usePromptImageStore((s) => s.setAspectRatio);
const resolution = usePromptImageStore((s) => s.resolution);
const setResolution = usePromptImageStore((s) => s.setResolution);
const generationCount = usePromptImageStore((s) => s.generationCount);
const setGenerationCount = usePromptImageStore((s) => s.setGenerationCount);
const [isEnqueueing, setIsEnqueueing] = useState(false);
@@ -111,40 +107,23 @@ export const PromptBoxImage = ({
}, [isImageRowVisible, onImageRowVisibilityChange]);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "Wide",
selected: aspectRatio === "wide",
label: "3:2",
selected: aspectRatio === "3:2",
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
},
{
label: "Tall",
selected: aspectRatio === "tall",
label: "2:3",
selected: aspectRatio === "2:3",
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
},
{
label: "Square",
selected: aspectRatio === "square",
label: "1:1",
selected: aspectRatio === "1:1",
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
},
]);
const [resolutionList, setResolutionList] = useState<PopoverItem[]>([
{
label: "1K",
selected: resolution === "1k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "2K",
selected: resolution === "2k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
{
label: "4K",
selected: resolution === "4k",
icon: <FontAwesomeIcon icon={faExpand} className="h-4 w-4" />,
},
]);
const [generationCountList, setGenerationCountList] = useState<PopoverItem[]>(
[],
[]
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -172,14 +151,14 @@ export const PromptBoxImage = ({
const caps = getCapabilitiesForModel(selectedModel);
const defaultCount = Math.min(
Math.max(1, caps.defaultGenerationCount ?? 1),
caps.maxGenerationCount,
caps.maxGenerationCount
);
setGenerationCount(defaultCount);
const items: PopoverItem[] = Array.from(
{ length: caps.maxGenerationCount },
(_, i) => i + 1,
(_, i) => i + 1
).map((count) => ({
label: String(count),
selected: count === defaultCount,
@@ -193,7 +172,7 @@ export const PromptBoxImage = ({
prev.map((item) => ({
...item,
selected: item.label === String(generationCount),
})),
}))
);
}, [generationCount]);
@@ -201,34 +180,21 @@ export const PromptBoxImage = ({
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected: item.label.toLowerCase() === aspectRatio,
})),
selected: item.label === aspectRatio,
}))
);
}, [aspectRatio]);
useEffect(() => {
setResolutionList((prev) =>
prev.map((item) => ({
...item,
selected: item.label.toLowerCase() === resolution,
})),
);
}, [resolution]);
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
setAspectRatio(selectedItem.label.toLowerCase() as any);
setAspectRatio(selectedItem.label as any);
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
}))
);
};
const handleResolutionSelect = (selectedItem: PopoverItem) => {
setResolution(selectedItem.label.toLowerCase() as any);
};
const handleGenerationCountSelect = (selectedItem: PopoverItem) => {
const count = parseInt(selectedItem.label, 10);
setGenerationCount(count);
@@ -236,7 +202,7 @@ export const PromptBoxImage = ({
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
}))
);
};
@@ -269,8 +235,8 @@ export const PromptBoxImage = ({
return;
}
console.debug("Selected model:", selectedModel);
console.debug("Prompt:", prompt);
console.debug("Selected model:", selectedModel)
console.debug("Prompt:", prompt)
setIsEnqueueing(true);
@@ -287,13 +253,11 @@ export const PromptBoxImage = ({
try {
const aspectRatio = getCurrentAspectRatio();
const resolution = getCurrentResolution();
const request: EnqueueTextToImageRequest = {
prompt: prompt,
model: selectedModel,
aspect_ratio: aspectRatio,
image_resolution: resolution,
number_images: generationCount,
frontend_caller: "text_to_image",
frontend_subscriber_id: subscriberId,
@@ -304,9 +268,9 @@ export const PromptBoxImage = ({
!!referenceImages &&
referenceImages.length > 0
) {
request.image_media_tokens = referenceImages
.map((image) => image.mediaToken)
.filter((t) => t.length > 0);
request.image_media_tokens = referenceImages.map(
(image) => image.mediaToken
);
}
const generateResponse = await EnqueueTextToImage(request);
@@ -330,31 +294,17 @@ export const PromptBoxImage = ({
const getCurrentAspectRatio = (): EnqueueTextToImageSize => {
const selected = aspectRatioList.find((item) => item.selected);
switch (selected?.label.toLowerCase()) {
case "wide":
switch (selected?.label) {
case "3:2":
return EnqueueTextToImageSize.Wide;
case "tall":
case "2:3":
return EnqueueTextToImageSize.Tall;
case "square":
case "1:1":
default:
return EnqueueTextToImageSize.Square;
}
};
const getCurrentResolution = (): EnqueueTextToImageResolution | undefined => {
const selected = resolutionList.find((item) => item.selected);
switch (selected?.label) {
case "1k":
return EnqueueTextToImageResolution.OneK;
case "2k":
return EnqueueTextToImageResolution.TwoK;
case "4k":
return EnqueueTextToImageResolution.FourK;
default:
return undefined;
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -381,7 +331,7 @@ export const PromptBoxImage = ({
visible={true}
maxImagePromptCount={Math.max(
1,
selectedModel?.maxImagePromptCount ?? 1,
selectedModel?.maxImagePromptCount ?? 1
)}
allowUpload={true}
referenceImages={referenceImages}
@@ -395,7 +345,7 @@ export const PromptBoxImage = ({
src={image.url}
alt="Reference preview"
className="w-full h-full object-contain"
/>,
/>
);
setIsModalOpen(true);
}}
@@ -410,7 +360,7 @@ export const PromptBoxImage = ({
"rounded-t-none",
isFocused
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
: "ring-1 ring-transparent"
)}
>
<div className="flex justify-center gap-2">
@@ -425,7 +375,7 @@ export const PromptBoxImage = ({
variant="action"
className={twMerge(
"h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all border-0 shadow-none",
isImageRowVisible && "text-primary",
isImageRowVisible && "text-primary"
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
@@ -462,47 +412,26 @@ export const PromptBoxImage = ({
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{selectedModel?.canChangeAspectRatio && (
<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={getCurrentResolutionIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
)}
{selectedModel?.canChangeResolution && (
<Tooltip
content="Resolution"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={resolutionList}
onSelect={handleResolutionSelect}
mode="toggle"
panelTitle="Resolution"
showIconsInList
triggerIcon={
<FontAwesomeIcon icon={faExpand} className="h-4 w-4" />
}
/>
</Tooltip>
)}
<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={getCurrentResolutionIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
@@ -15,13 +15,11 @@ import {
faMessageCheck,
faSparkles,
faSpinnerThird,
faSquare,
faPortrait,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangle,
faSquare,
faRectangleVertical,
} from "@fortawesome/pro-regular-svg-icons";
import { faRectangle } from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
@@ -56,7 +54,7 @@ interface PromptBoxVideoProps {
useJobContext: () => JobContextType;
onEnqueuePressed?: (
prompt: string,
subscriberId: string,
subscriberId: string
) => void | Promise<void>;
selectedModel?: VideoModel;
imageMediaId?: string;
@@ -97,14 +95,14 @@ export const PromptBoxVideo = ({
const setUseSystemPrompt = usePromptVideoStore((s) => s.setUseSystemPrompt);
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
const setGenerateWithSound = usePromptVideoStore(
(s) => s.setGenerateWithSound,
(s) => s.setGenerateWithSound
);
const resolution = usePromptVideoStore((s) => s.resolution);
const setResolution = usePromptVideoStore((s) => s.setResolution);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
[]
);
const referenceImages = usePromptVideoStore((s) => s.referenceImages);
const setReferenceImages = usePromptVideoStore((s) => s.setReferenceImages);
@@ -126,7 +124,7 @@ export const PromptBoxVideo = ({
// When switching to a new model, the existing resolution might not be correct.
// This is a gross and nasty hack to handle the case where the resolution is not found.
const resolutionExists = selectedModel.sizeOptions.some(
(option) => option.textLabel === resolution,
(option) => option.textLabel === resolution
);
const useFirstOption = !resolutionExists;
@@ -137,7 +135,7 @@ export const PromptBoxVideo = ({
faIcon = faRectangle;
break;
case SizeIconOption.Portrait:
faIcon = faRectangleVertical;
faIcon = faPortrait;
break;
case SizeIconOption.Square:
faIcon = faSquare;
@@ -153,7 +151,7 @@ export const PromptBoxVideo = ({
});
} else {
const resolutionExists = DEFAULT_RESOLUTIONS.some(
(option) => option.textLabel === resolution,
(option) => option.textLabel === resolution
);
const useFirstOption = !resolutionExists;
@@ -164,7 +162,7 @@ export const PromptBoxVideo = ({
faIcon = faRectangle;
break;
case SizeIconOption.Portrait:
faIcon = faRectangleVertical;
faIcon = faPortrait;
break;
case SizeIconOption.Square:
faIcon = faSquare;
@@ -215,7 +213,7 @@ export const PromptBoxVideo = ({
resolutionOptions.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
}))
);
};
@@ -331,11 +329,12 @@ export const PromptBoxVideo = ({
};
const getGrokAspectRatio = (): GROK_ASPECT_RATIO => {
// NB: This function was just written to give us better type safety.
// NB: This function was just written to give us better type safety.
// There has to be a cleaner appraoach.
const maybeAspectRatio = selectedModel?.sizeOptions?.find(
(option) => option.textLabel === resolution,
)?.tauriValue;
const maybeAspectRatio = selectedModel?.
sizeOptions?.
find((option) => option.textLabel === resolution)?.
tauriValue;
switch (maybeAspectRatio) {
case "landscape":
@@ -347,7 +346,7 @@ export const PromptBoxVideo = ({
default:
return "landscape";
}
};
}
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
@@ -387,7 +386,7 @@ export const PromptBoxVideo = ({
src={image.url}
alt="Reference preview"
className="w-full h-full object-contain"
/>,
/>
);
setIsModalOpen(true);
}}
@@ -404,7 +403,7 @@ export const PromptBoxVideo = ({
isImageRowVisible && "rounded-t-none",
isFocused
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
: "ring-1 ring-transparent"
)}
>
<div className="flex justify-center gap-2">
@@ -8,19 +8,16 @@ export interface RefImage {
}
// ----- 2D Prompt Box Store -----
type AspectRatio = "wide" | "tall" | "square";
type Resolution = "1k" | "2k" | "4k";
type AspectRatio = "3:2" | "2:3" | "1:1";
interface Prompt2DStore {
prompt: string;
aspectRatio: AspectRatio;
resolution: Resolution;
useSystemPrompt: boolean;
referenceImages: RefImage[];
generationCount: number;
setPrompt: (prompt: string) => void;
setAspectRatio: (ratio: AspectRatio) => void;
setResolution: (resolution: Resolution) => void;
setUseSystemPrompt: (value: boolean) => void;
setReferenceImages: (images: RefImage[]) => void;
setGenerationCount: (count: number) => void;
@@ -28,14 +25,12 @@ interface Prompt2DStore {
export const usePrompt2DStore = create<Prompt2DStore>()((set) => ({
prompt: "",
aspectRatio: "wide",
resolution: "1k",
aspectRatio: "3:2",
useSystemPrompt: true,
referenceImages: [],
generationCount: 1,
setPrompt: (prompt) => set({ prompt }),
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
setResolution: (resolution) => set({ resolution }),
setUseSystemPrompt: (useSystemPrompt) => set({ useSystemPrompt }),
setReferenceImages: (referenceImages) => set({ referenceImages }),
setGenerationCount: (generationCount) => set({ generationCount }),
@@ -46,22 +41,18 @@ export { usePrompt2DStore as usePromptStore };
// ----- 3D Prompt Box Store -----
interface Prompt3DStore {
prompt: string;
resolution: Resolution;
useSystemPrompt: boolean;
referenceImages: RefImage[];
setPrompt: (prompt: string) => void;
setResolution: (resolution: Resolution) => void;
setUseSystemPrompt: (value: boolean) => void;
setReferenceImages: (images: RefImage[]) => void;
}
export const usePrompt3DStore = create<Prompt3DStore>()((set) => ({
prompt: "",
resolution: "1k",
useSystemPrompt: true,
referenceImages: [],
setPrompt: (prompt) => set({ prompt }),
setResolution: (resolution) => set({ resolution }),
setUseSystemPrompt: (useSystemPrompt) => set({ useSystemPrompt }),
setReferenceImages: (referenceImages) => set({ referenceImages }),
}));
@@ -70,13 +61,11 @@ export const usePrompt3DStore = create<Prompt3DStore>()((set) => ({
interface PromptImageStore {
prompt: string;
aspectRatio: AspectRatio;
resolution: Resolution;
useSystemPrompt: boolean;
referenceImages: RefImage[];
generationCount: number;
setPrompt: (prompt: string) => void;
setAspectRatio: (ratio: AspectRatio) => void;
setResolution: (resolution: Resolution) => void;
setUseSystemPrompt: (value: boolean) => void;
setReferenceImages: (images: RefImage[]) => void;
setGenerationCount: (count: number) => void;
@@ -84,14 +73,12 @@ interface PromptImageStore {
export const usePromptImageStore = create<PromptImageStore>()((set) => ({
prompt: "",
aspectRatio: "wide",
resolution: "1k",
aspectRatio: "3:2",
useSystemPrompt: true,
referenceImages: [],
generationCount: 1,
setPrompt: (prompt) => set({ prompt }),
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
setResolution: (resolution) => set({ resolution }),
setUseSystemPrompt: (useSystemPrompt) => set({ useSystemPrompt }),
setReferenceImages: (referenceImages) => set({ referenceImages }),
setGenerationCount: (generationCount) => set({ generationCount }),
@@ -131,22 +118,12 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
}));
// ----- Edit Prompt Box Store -----
type EditAspectRatio = "auto" | "wide" | "tall" | "square";
interface PromptEditStore {
referenceImages: RefImage[];
aspectRatio: EditAspectRatio;
resolution: Resolution;
setReferenceImages: (images: RefImage[]) => void;
setAspectRatio: (ratio: EditAspectRatio) => void;
setResolution: (resolution: Resolution) => void;
}
export const usePromptEditStore = create<PromptEditStore>()((set) => ({
referenceImages: [],
aspectRatio: "auto",
resolution: "1k",
setReferenceImages: (referenceImages) => set({ referenceImages }),
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
setResolution: (resolution) => set({ resolution }),
}));
@@ -36,12 +36,6 @@ export class ImageModel extends Model {
// If true, it'll be displayed on the text-to-image page.
readonly canTextToImage: boolean;
// Whether the model supports changing resolution (e.g. 1k, 2k, 4k)
readonly canChangeResolution: boolean;
// Whether the model supports changing aspect ratio
readonly canChangeAspectRatio: boolean;
constructor(args: {
id: string;
tauriId: string;
@@ -59,8 +53,6 @@ export class ImageModel extends Model {
canUseImagePrompt?: boolean;
maxImagePromptCount?: number;
canTextToImage?: boolean;
canChangeResolution?: boolean;
canChangeAspectRatio?: boolean;
tags?: ModelTag[];
progressBarTime?: number;
}) {
@@ -84,8 +76,6 @@ export class ImageModel extends Model {
this.canUseImagePrompt = args.canUseImagePrompt ?? false;
this.maxImagePromptCount = Math.max(0, args.maxImagePromptCount ?? 1);
this.canTextToImage = args.canTextToImage === false ? false : true; // Default to true !
this.canChangeResolution = args.canChangeResolution ?? false;
this.canChangeAspectRatio = args.canChangeAspectRatio ?? false;
}
// If the model is a "Nano Banana"-type model, we may want to enable certain features.
@@ -20,7 +20,7 @@ const cfg = (
id: string;
category: ModelCategory;
info: ModelInfo;
},
}
): ModelConfig => ({
label: m.label ?? m.info.name,
description: m.description,
@@ -134,21 +134,6 @@ const ALL_MODELS: ModelConfig[] = [
},
tags: [ModelTag.InstructiveEdit, ModelTag.NonMaskedInpainting],
}),
cfg({
id: "gemini_25_flash",
category: "image",
info: {
name: "Nano Banana",
tauri_id: "gemini_25_flash",
creator: mc.Google,
},
description: "Incredibly instructive editing",
badges: [{ label: "25 sec." }],
capabilities: {
maxGenerationCount: 4,
},
tags: [ModelTag.InstructiveEdit, ModelTag.NonMaskedInpainting],
}),
cfg({
id: "nano_banana_pro",
category: "image",
@@ -267,22 +252,23 @@ export const getModelsByCategory = (category: ModelCategory): ModelConfig[] =>
export const getInstructiveImageEditModels = (): ModelConfig[] =>
ALL_MODELS.filter(
(m) => m.category === "image" && m.tags?.includes("instructiveEdit"),
(m) => m.category === "image" && m.tags?.includes("instructiveEdit")
);
export const getMaskedInpaintModels = (): ModelConfig[] =>
ALL_MODELS.filter(
(m) =>
m.category === "image" && m.tags?.includes(ModelTag.MaskedInpainting),
(m) => m.category === "image" && m.tags?.includes(ModelTag.MaskedInpainting)
);
export const lookupModelByTauriId = (
tauriId: string,
tauriId: string
): ModelConfig | undefined =>
ALL_MODELS.find((m) => m.info.tauri_id === tauriId);
// Single exported capability resolver so callers never need to touch anything else
export const getCapabilitiesForModel = (model?: Model): ModelCapabilities => {
export const getCapabilitiesForModel = (
model?: Model
): ModelCapabilities => {
if (!model) return DEFAULT_CAPABILITIES;
const cfg = lookupModelByTauriId(model.tauriId);
return cfg?.capabilities ?? DEFAULT_CAPABILITIES;
@@ -19,7 +19,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: false,
maxImagePromptCount: 6,
progressBarTime: 10000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "midjourney",
@@ -35,7 +34,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: false,
maxImagePromptCount: 6,
progressBarTime: 45000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_pro_1_1_ultra",
@@ -51,7 +49,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 35000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_pro_1_1",
@@ -67,7 +64,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 10000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_1_dev",
@@ -83,7 +79,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 10000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_1_schnell",
@@ -99,7 +94,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 10000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "gemini_25_flash",
@@ -118,8 +112,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
progressBarTime: 25000,
// canChangeResolution: true,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "nano_banana_pro",
@@ -138,8 +130,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canTextToImage: true,
tags: [ModelTag.InstructiveEdit],
progressBarTime: 25000,
canChangeResolution: true,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "gpt_image_1",
@@ -157,7 +147,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canUseImagePrompt: true,
maxImagePromptCount: 6,
progressBarTime: 60000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_pro_kontext_max",
@@ -174,7 +163,6 @@ export const IMAGE_MODELS: ImageModel[] = [
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
progressBarTime: 20000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_pro_inpaint",
@@ -192,7 +180,6 @@ export const IMAGE_MODELS: ImageModel[] = [
editingIsInpainting: true,
canTextToImage: false,
progressBarTime: 30000,
canChangeAspectRatio: true,
}),
new ImageModel({
id: "flux_dev_juggernaut_inpaint",
@@ -210,12 +197,11 @@ export const IMAGE_MODELS: ImageModel[] = [
editingIsInpainting: true,
canTextToImage: false,
progressBarTime: 10000,
canChangeAspectRatio: true,
}),
];
export const IMAGE_MODELS_BY_ID: Map<string, ImageModel> = new Map(
IMAGE_MODELS.map((model) => [model.id, model]),
IMAGE_MODELS.map((model) => [model.id, model])
);
if (IMAGE_MODELS_BY_ID.size !== IMAGE_MODELS.length) {
@@ -23,9 +23,6 @@ export interface EnqueueTextToImageRequest {
// The desired output aspect ratio.
aspect_ratio?: EnqueueTextToImageSize;
// Image resolution. Support: Nano Banana Pro
image_resolution?: EnqueueTextToImageResolution;
// The number of images to generate.
number_images?: number;
@@ -49,7 +46,6 @@ interface EnqueueTextToImageRawRequest {
prompt?: string;
model?: EnqueueTextToImageModel | string; // TODO: Shouldn't allow string
aspect_ratio?: EnqueueTextToImageSize;
image_resolution?: EnqueueTextToImageResolution;
number_images?: number;
image_media_tokens?: string[];
frontend_caller?: string;
@@ -69,12 +65,6 @@ export enum EnqueueTextToImageSize {
Tall = "tall",
}
export enum EnqueueTextToImageResolution {
OneK = "one_k",
TwoK = "two_k",
FourK = "four_k",
}
export interface EnqueueTextToImageError extends CommandResult {
error_type: EnqueueTextToImageErrorType;
error_message?: string;
@@ -117,10 +107,6 @@ export const EnqueueTextToImage = async (request: EnqueueTextToImageRequest) : P
mutableRequest.aspect_ratio = request.aspect_ratio;
}
if (!!request.image_resolution) {
mutableRequest.image_resolution = request.image_resolution;
}
if (!!request.number_images) {
mutableRequest.number_images = request.number_images;
}