new text to image prompt box image prompts (#800)

- image prompts linked to models (maximum and if it's allowed or not)
- lots of styling updates
- promptbox doesn't overflow bottom anymore (dynamic height detection)
- selected image prompts can be clicked to be previewed in a modal
<img width="938" height="472" alt="image"
src="https://github.com/user-attachments/assets/e4fc7445-0031-4adf-a6f6-dbcbe1c3ea67"
/>
- @echelon on handleEnqueue - image prompts need to be added

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
BFlatandBrandon Thomas authored and GitHub committed 2025-08-28 23:10:16 -04:00
1 parent 7557e586ba
commit c017fbc25a
10 files changed
+505 -132

No files matched your search

@@ -32,6 +32,7 @@ use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
@@ -73,6 +74,13 @@ pub struct EnqueueTextToImageRequest {
/// The number of images to generate.
pub number_images: Option<u32>,
/// OPTIONAL.
/// Reference images (without semantics)
/// The purpose varies on a model-by-model basis, but they
/// are not semantically treated as "style reference",
/// "character/object reference", etc.
pub image_media_tokens: Option<Vec<MediaFileToken>>,
/// OPTIONAL.
/// Name of the frontend caller.
/// We'll use this to selectively trigger events.
@@ -46,13 +46,6 @@ pub async fn handle_gemini_25_flash_artcraft(
let uuid_idempotency_token = generate_random_uuid();
// let image_media_token = match &request.image_media_token {
// Some(token) => token.clone(),
// None => {
// return Err(InternalImageInpaintError::NoSourceImageSpecified);
// },
// };
let num_images = match request.number_images{
None => None,
Some(1) => Some(Gemini25FlashEditImageNumImages::One),
@@ -65,7 +58,7 @@ pub async fn handle_gemini_25_flash_artcraft(
let request = Gemini25FlashEditImageRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_media_tokens: Some(vec![]), // TODO: Needs image media tokens.
image_media_tokens: request.image_media_tokens.clone(),
num_images,
image_quality: None,
};
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { JobContextType } from "@storyteller/common";
import { PromptBoxImage } from "@storyteller/ui-promptbox";
import { uploadImage } from "../../components/reusable/UploadModalMedia/uploadImage";
import BackgroundGallery from "./BackgroundGallery";
import {
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
@@ -21,6 +22,7 @@ import {
galleryModalLightboxVisible,
} from "@storyteller/ui-gallery-modal";
import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
const PAGE_ID: ModelPage = ModelPage.TextToImage;
@@ -30,8 +32,12 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const startBatch = useTextToImageStore((s) => s.startBatch);
const completeBatch = useTextToImageStore((s) => s.completeBatch);
const resetBatches = useTextToImageStore((s) => s.reset);
const [imageRowVisible, setImageRowVisible] = useState(false);
const promptContentRef = useRef<HTMLDivElement>(null);
const [promptHeight, setPromptHeight] = useState<number>(138);
const selectedImageModel : ImageModel | undefined = getSelectedImageModel(PAGE_ID);
const selectedImageModel: ImageModel | undefined =
getSelectedImageModel(PAGE_ID);
const jobContext: JobContextType = {
jobTokens: [],
@@ -59,7 +65,18 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
return () => window.removeEventListener("resize", onResize);
}, []);
const bottomOffsetPx = 138;
useEffect(() => {
const el = promptContentRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const update = () => setPromptHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(() => update());
ro.observe(el);
return () => ro.disconnect();
}, []);
const bottomMarginPx = 24;
const bottomOffsetPx = promptHeight + bottomMarginPx;
const targetTop = showPromptAtBottom
? Math.max(0, vh - bottomOffsetPx)
: Math.floor(vh / 2);
@@ -80,7 +97,12 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
<div className="relative h-full w-full p-16">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md pb-12">
{!showPromptAtBottom && (
<div className="relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl">
<div
className={twMerge(
"relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl",
imageRowVisible && "mb-80",
)}
>
<span className="text-7xl font-bold">Generate Image</span>
<span className="pt-2 text-xl opacity-80">
Add a prompt, then generate
@@ -89,7 +111,10 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
)}
{hasAnyBatches && (
<div className="h-full w-full overflow-y-auto pb-40">
<div
className="h-full w-full overflow-y-auto"
style={{ paddingBottom: bottomOffsetPx + 24 }}
>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 pr-2">
{inverseBatch.map((batch) => (
<div key={batch.id} className="flex items-start gap-4">
@@ -154,7 +179,9 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
style={promptAnim}
>
{showPromptAtBottom && batches.length > 0 && (
<div className="absolute -top-9 flex w-full justify-end">
<div
className={`absolute ${imageRowVisible ? "-top-[108px]" : "-top-9"} flex w-full justify-end`}
>
<button
onClick={() => resetBatches()}
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
@@ -163,18 +190,22 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
</button>
</div>
)}
<PromptBoxImage
useJobContext={() => {
return jobContext;
}}
selectedModel={selectedImageModel}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onEnqueuePressed={async (prompt, count, subscriberId) => {
const modelLabel = selectedImageModel?.fullName ?? "";
startBatch(prompt, count, modelLabel, subscriberId);
}}
/>
<div ref={promptContentRef}>
<PromptBoxImage
useJobContext={() => {
return jobContext;
}}
uploadImage={uploadImage}
selectedModel={selectedImageModel}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onImageRowVisibilityChange={setImageRowVisible}
onEnqueuePressed={async (prompt, count, subscriberId) => {
const modelLabel = selectedImageModel?.fullName ?? "";
startBatch(prompt, count, modelLabel, subscriberId);
}}
/>
</div>
</animated.div>
{!showPromptAtBottom && <BackgroundGallery />}
@@ -59,7 +59,7 @@ export const Button = ({
);
const className = twMerge(
"w-fit text-sm font-medium rounded-lg px-3 py-2 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center",
"w-fit text-sm font-medium rounded-lg px-3 py-2 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform",
getVariantClassNames(propsVariant),
propsClassName,
disabledClass
@@ -89,6 +89,7 @@ export const PopoverMenu = ({
"flex gap-2 items-center justify-center outline-none",
"transition-all duration-150",
"bg-[#5F5F68]/60 px-3 text-white hover:bg-[#5F5F68]/90",
"active:scale-95 transform",
buttonClassName
);
@@ -1,13 +1,20 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, ReactNode } from "react";
import { unstable_batchedUpdates } from "react-dom";
import { useSignals } from "@preact/signals-react/runtime";
import { JobContextType } from "@storyteller/common";
import {
JobContextType,
UploaderState,
UploaderStates,
} from "@storyteller/common";
import { downloadFileFromUrl } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import {
EnqueueTextToImage,
EnqueueTextToImageRequest,
EnqueueTextToImageSize,
} from "@storyteller/tauri-api";
import {
@@ -15,25 +22,37 @@ import {
faMessageCheck,
faSparkles,
faSpinnerThird,
faTimes,
faPlus,
faImages,
faCopy,
faTrashAlt,
faPlus,
faXmark,
faImages,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangle,
faSquare,
faRectangleVertical,
faImage,
} 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";
import { ImageModel, ModelInfo, getCapabilitiesForModel } from "@storyteller/model-list";
import { ImageModel, getCapabilitiesForModel } from "@storyteller/model-list";
import { usePromptImageStore, RefImage } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { twMerge } from "tailwind-merge";
interface PromptBoxImageProps {
useJobContext: () => JobContextType;
uploadImage?: ({
title,
assetFile,
progressCallback,
}: {
title: string;
assetFile: File;
progressCallback: (newState: UploaderState) => void;
}) => Promise<void>;
onEnqueuePressed?: (
prompt: string,
count: number,
@@ -42,14 +61,17 @@ interface PromptBoxImageProps {
selectedModel?: ImageModel;
imageMediaId?: string;
url?: string;
onImageRowVisibilityChange?: (visible: boolean) => void;
}
export const PromptBoxImage = ({
useJobContext,
uploadImage,
onEnqueuePressed,
selectedModel,
imageMediaId,
url,
onImageRowVisibilityChange,
}: PromptBoxImageProps) => {
useSignals();
@@ -67,7 +89,7 @@ export const PromptBoxImage = ({
}, [imageMediaId, url]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [content, setContent] = useState("");
const [content, setContent] = useState<ReactNode>(null);
const prompt = usePromptImageStore((s) => s.prompt);
const setPrompt = usePromptImageStore((s) => s.setPrompt);
@@ -86,6 +108,15 @@ export const PromptBoxImage = ({
const [uploadingImages, _setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const isImageRowVisible =
showImagePrompts ||
referenceImages.length > 0 ||
uploadingImages.length > 0;
useEffect(() => {
onImageRowVisibilityChange?.(isImageRowVisible);
}, [isImageRowVisible, onImageRowVisibilityChange]);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "3:2",
@@ -198,51 +229,150 @@ export const PromptBoxImage = ({
};
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const maxImagePrompts = Math.max(1, selectedModel?.maxImagePromptCount ?? 1);
const availableSlotsRender = Math.max(
0,
maxImagePrompts - referenceImages.length - uploadingImages.length
);
const usedSlotsRender = Math.min(
maxImagePrompts,
referenceImages.length + uploadingImages.length
);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const handleGallerySelect = () => setIsGalleryModalOpen(true);
const maxCount = Math.max(1, selectedModel?.maxImagePromptCount ?? 1);
const currentCount = referenceImages.length + uploadingImages.length;
const availableSlots = Math.max(0, maxCount - currentCount);
if (availableSlots <= 0) {
toast.error("Image limit reached for this model.");
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
const filesToProcess = files.slice(0, availableSlots);
if (filesToProcess.length < files.length) {
toast.error(
`Max ${availableSlots} image${
availableSlots === 1 ? "" : "s"
} allowed. Added the first ${filesToProcess.length}.`
);
}
filesToProcess.forEach((file) => {
const uploadId = Math.random().toString(36).substring(7);
_setUploadingImages((prev) => [...prev, { id: uploadId, file }]);
const reader = new FileReader();
reader.onloadend = async () => {
if (uploadImage) {
await uploadImage({
title: `reference-image-${Math.random()
.toString(36)
.substring(2, 15)}`,
assetFile: file,
progressCallback: (newState: UploaderState) => {
if (newState.status === UploaderStates.success && newState.data) {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: newState.data,
};
unstable_batchedUpdates(() => {
_setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
const latestRefs =
usePromptImageStore.getState().referenceImages;
setReferenceImages([...latestRefs, referenceImage]);
});
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
unstable_batchedUpdates(() => {
_setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
});
}
},
});
} else {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: "",
};
unstable_batchedUpdates(() => {
_setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId)
);
const latestRefs = usePromptImageStore.getState().referenceImages;
setReferenceImages([...latestRefs, referenceImage]);
});
}
if (fileInputRef.current) fileInputRef.current.value = "";
};
reader.readAsDataURL(file);
});
};
const handleUploadClick = () => fileInputRef.current?.click();
const toggleImageOptions = () => {
const anyImages = referenceImages.length > 0 || uploadingImages.length > 0;
setShowImagePrompts((prev) => {
if (prev) {
return anyImages;
}
return true;
});
};
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
const maxSelect = Math.max(1, selectedModel?.maxImagePromptCount ?? 1);
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) {
return [];
return prev.filter((imageId) => imageId !== id);
}
return [id];
if (prev.length >= maxSelect) {
return prev;
}
return [...prev, id];
});
};
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
// Clear existing reference images first
setReferenceImages([]);
const maxCount = Math.max(1, selectedModel?.maxImagePromptCount ?? 1);
const availableSlots = Math.max(0, maxCount - referenceImages.length);
if (availableSlots <= 0) {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
return;
}
// Only take the first selected item
const item = selectedItems[0];
if (!item || !item.fullImage) return;
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "library-image"),
mediaToken: item.id,
};
setReferenceImages([referenceImage]);
const newRefs = [...referenceImages];
selectedItems.slice(0, availableSlots).forEach((item) => {
if (!item.fullImage) return;
newRefs.push({
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "library-image"),
mediaToken: item.id,
});
});
setReferenceImages(newRefs);
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleAction = (action: string) => {
switch (action) {
case "gallery":
handleGallerySelect();
break;
default:
console.log("Unknown action:", action);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text");
@@ -280,15 +410,21 @@ export const PromptBoxImage = ({
}, 10000);
const aspectRatio = getCurrentAspectRatio();
const generateResponse = await EnqueueTextToImage({
const request : EnqueueTextToImageRequest = {
prompt: prompt,
model: selectedModel,
aspect_ratio: aspectRatio,
number_images: generationCount,
frontend_caller: "text_to_image",
frontend_subscriber_id: subscriberId,
});
};
if (selectedModel?.canUseImagePrompt && !!referenceImages && referenceImages.length > 0) {
request.image_media_tokens = referenceImages.map((image) => image.mediaToken);
}
const generateResponse = await EnqueueTextToImage(request);
console.log("PromptBoxImage - generateResponse", generateResponse);
@@ -328,82 +464,217 @@ export const PromptBoxImage = ({
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setContent("");
setContent(null);
}}
className="max-w-4xl max-h-[80vh]"
>
{content}
</Modal>
<div className="relative z-20 flex 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 className="relative z-20 flex flex-col">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple={availableSlotsRender > 1}
/>
{isImageRowVisible && selectedModel?.canUseImagePrompt && (
<div className="absolute -top-[72px] left-0 glass w-[730px] rounded-t-xl flex">
<div className="grow grid grid-cols-1 gap-1 py-2 px-3">
<div className="flex gap-2">
<div className="flex flex-col grow gap-1">
<div className="flex items-center gap-2 opacity-90">
<FontAwesomeIcon icon={faImage} className="h-3.5 w-3.5" />
<span className="text-sm text-white font-medium flex items-center gap-1.5">
Image Prompts
<span className="text-white/60 font-semibold">
({usedSlotsRender}/{selectedModel?.maxImagePromptCount})
</span>
</span>
</div>
<span className="text-[13px] text-white/60">
Use the elements of an image
</span>
</div>
);
})}
<div className="flex gap-2">
{referenceImages
.slice(
0,
Math.max(0, selectedModel?.maxImagePromptCount ?? 1)
)
.map((image) => (
<div
key={image.id}
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={() => {
setContent(
<img
src={image.url}
alt="Reference preview"
className="w-full h-full object-contain"
/>
);
setIsModalOpen(true);
}}
>
<img
src={image.url}
alt="Reference"
className="h-full w-full object-cover"
/>
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveReference(image.id);
}}
className="opacity-0 group-hover:opacity-100 absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 hover:bg-red/70 text-white backdrop-blur-md transition-colors hover:bg-black cursor-pointer"
>
<FontAwesomeIcon
icon={faXmark}
className="h-2.5 w-2.5"
/>
</button>
</div>
))}
{uploadingImages
.slice(
0,
Math.max(
0,
(selectedModel?.maxImagePromptCount ?? 1) -
referenceImages.length
)
)
.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square overflow-hidden rounded-lg w-14 border-2 border-white/30"
>
<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>
);
})}
{referenceImages.length + uploadingImages.length <
(selectedModel?.maxImagePromptCount ?? 1) && (
<Tooltip
interactive={true}
position="top"
delay={100}
className="bg-[#46464B] p-2 -mb-0.5"
closeOnClick={true}
content={
<div className="flex flex-col gap-1.5">
<Button
variant="primary"
onClick={handleUploadClick}
icon={faPlus}
className="w-full"
>
Upload
</Button>
<Button
variant="action"
onClick={() => setIsGalleryModalOpen(true)}
icon={faImages}
className="w-full bg-[#686870] hover:bg-[#78787F]"
>
Pick from library
</Button>
</div>
}
>
<Button
variant="action"
className="bg-white/10 hover:bg-white/20 aspect-square w-full overflow-hidden rounded-lg w-14 border-dashed border-2 border-white/30 hover:border-white/50 transition-all"
onClick={handleUploadClick}
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl opacity-80"
/>
</Button>
</Tooltip>
)}
</div>
</div>
</div>
<div className="col-span-2 flex items-center">
<div className="flex items-center gap-2 w-[1px] h-full bg-white/10 rounded-lg" />
<div className="p-2">
<Button
variant="action"
icon={faTrashAlt}
className="h-8 w-3 bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90"
onClick={() => {
setReferenceImages([]);
}}
/>
</div>
</div>
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<div
className={twMerge(
"glass w-[730px] rounded-xl p-4",
isImageRowVisible && "rounded-t-none"
)}
>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
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" />
}
/>
{selectedModel?.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",
isImageRowVisible && "text-primary"
)}
onClick={toggleImageOptions}
>
<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="Choose the image from library (+) and describe what you want to happen in the video..."
placeholder="Describe what you want in the 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}
@@ -497,7 +768,7 @@ export const PromptBoxImage = ({
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
maxSelections={Math.max(1, availableSlotsRender)}
onUseSelected={handleGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
@@ -11,6 +11,11 @@ interface TooltipProps {
closeOnClick?: boolean;
imageSrc?: string;
description?: string;
/**
* When true, the tooltip can be hovered and clicked without closing
* immediately when the cursor leaves the trigger. Useful for menus.
*/
interactive?: boolean;
}
export const Tooltip = ({
@@ -22,10 +27,13 @@ export const Tooltip = ({
closeOnClick = false,
imageSrc,
description,
interactive = false,
}: TooltipProps) => {
const [isShowing, setIsShowing] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [isHoveringTrigger, setIsHoveringTrigger] = useState(false);
const [isHoveringTooltip, setIsHoveringTooltip] = useState(false);
const checkForOpenPopovers = () => {
if (!triggerRef.current) return false;
@@ -105,16 +113,29 @@ export const Tooltip = ({
}
};
useEffect(() => {
if (!checkForOpenPopovers()) {
const shouldShow =
isHoveringTrigger || (interactive && isHoveringTooltip);
setIsShowing(shouldShow);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHoveringTrigger, isHoveringTooltip, interactive]);
return (
<div
ref={triggerRef}
onMouseEnter={() => {
setIsHoveringTrigger(true);
if (!checkForOpenPopovers()) {
setIsShowing(true);
}
}}
onMouseLeave={() => {
setIsShowing(false);
setIsHoveringTrigger(false);
if (!interactive) {
setIsShowing(false);
}
}}
onClick={handleClick}
className="relative"
@@ -134,6 +155,8 @@ export const Tooltip = ({
>
<div
ref={tooltipRef}
onMouseEnter={() => interactive && setIsHoveringTooltip(true)}
onMouseLeave={() => interactive && setIsHoveringTooltip(false)}
style={{
...getStyleForPosition(),
transitionDelay: `${delay}ms`,
@@ -142,7 +165,8 @@ export const Tooltip = ({
transitionTimingFunction: "ease-out",
}}
className={twMerge(
"pointer-events-none absolute z-10 w-max rounded-lg bg-[#5F5F68] px-2.5 py-1.5 text-[13px] font-medium text-white shadow-xl",
"absolute z-10 w-max rounded-lg bg-[#5F5F68] px-2.5 py-1.5 text-[13px] font-medium text-white shadow-xl",
interactive ? "pointer-events-auto" : "pointer-events-none",
className ? className : ""
)}
>
@@ -16,6 +16,12 @@ export class ImageModel extends Model {
// For inpainting models, does it require sending a mask?
readonly usesInpaintingMask: boolean;
// Whether this model supports additional image prompts (reference images)
readonly canUseImagePrompt: boolean;
// Maximum number of image prompts that can be attached
readonly maxImagePromptCount: number;
constructor(args: {
id: string;
tauriId: string;
@@ -29,6 +35,8 @@ export class ImageModel extends Model {
defaultGenerationCount: number;
canEditImages?: boolean;
usesInpaintingMask?: boolean;
canUseImagePrompt?: boolean;
maxImagePromptCount?: number;
tags?: ModelTag[];
}) {
if (args.maxGenerationCount < 1) {
@@ -38,12 +46,16 @@ export class ImageModel extends Model {
throw new Error("defaultGenerationCount must be at least 1");
}
if (args.defaultGenerationCount > args.maxGenerationCount) {
throw new Error("defaultGenerationCount must be less than or equal to maxGenerationCount");
throw new Error(
"defaultGenerationCount must be less than or equal to maxGenerationCount"
);
}
super(args);
this.maxGenerationCount = args.maxGenerationCount;
this.defaultGenerationCount = args.defaultGenerationCount;
this.canEditImages = args.canEditImages ?? false;
this.usesInpaintingMask = args.usesInpaintingMask ?? false;
}
this.usesInpaintingMask = args.usesInpaintingMask ?? false;
this.canUseImagePrompt = args.canUseImagePrompt ?? false;
this.maxImagePromptCount = Math.max(0, args.maxImagePromptCount ?? 1);
}
}
@@ -14,6 +14,8 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["15 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: false,
maxImagePromptCount: 6,
}),
new ImageModel({
id: "flux_pro_1_1_ultra",
@@ -26,6 +28,8 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["15 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
maxImagePromptCount: 6,
}),
new ImageModel({
id: "flux_pro_1_1",
@@ -38,6 +42,8 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["15 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
maxImagePromptCount: 6,
}),
new ImageModel({
id: "flux_1_dev",
@@ -50,6 +56,8 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["15 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
maxImagePromptCount: 6,
}),
new ImageModel({
id: "flux_1_schnell",
@@ -62,6 +70,8 @@ export const IMAGE_MODELS: ImageModel[] = [
selectorBadges: ["15 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
maxImagePromptCount: 6,
}),
new ImageModel({
id: "gemini_25_flash",
@@ -75,6 +85,8 @@ export const IMAGE_MODELS: ImageModel[] = [
maxGenerationCount: 4,
defaultGenerationCount: 1,
canEditImages: true,
canUseImagePrompt: true,
maxImagePromptCount: 6,
tags: [ModelTag.InstructiveEdit],
}),
new ImageModel({
@@ -89,6 +101,8 @@ export const IMAGE_MODELS: ImageModel[] = [
maxGenerationCount: 1,
defaultGenerationCount: 1,
tags: [ModelTag.InstructiveEdit],
canUseImagePrompt: true,
maxImagePromptCount: 6,
}),
new ImageModel({
id: "flux_pro_kontext_max",
@@ -14,11 +14,25 @@ export enum EnqueueTextToImageErrorType {
}
export interface EnqueueTextToImageRequest {
prompt?: string;
// The model to use.
model?: Model | ModelInfo | EnqueueTextToImageModel;
// The text prompt.
prompt?: string;
// The desired output aspect ratio.
aspect_ratio?: EnqueueTextToImageSize;
// The number of images to generate.
number_images?: number;
// Optional image-to-image prompts
// These are not semantic buckets, but rather just "reference images",
// and models do not have any further instruction about them. (ie. they
// are not "style", "character", etc. references)
image_media_tokens?: string[];
// Optional frontend state to return later.
// TODO: Actual enum.
frontend_caller?: string;
@@ -33,6 +47,7 @@ interface EnqueueTextToImageRawRequest {
model?: EnqueueTextToImageModel | string; // TODO: Shouldn't allow string
aspect_ratio?: EnqueueTextToImageSize;
number_images?: number;
image_media_tokens?: string[];
frontend_caller?: string;
frontend_subscriber_id?: string;
}
@@ -93,6 +108,10 @@ export const EnqueueTextToImage = async (request: EnqueueTextToImageRequest) : P
mutableRequest.number_images = request.number_images;
}
if (!!request.image_media_tokens && request.image_media_tokens.length > 0) {
mutableRequest.image_media_tokens = request.image_media_tokens;
}
if (!!request.frontend_caller) {
mutableRequest.frontend_caller = request.frontend_caller;
}