Compare commits

..
15 changed files with 130 additions and 47 deletions
@@ -37,9 +37,6 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/tutorial-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
}
@@ -2,9 +2,6 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/components/button"
},
@@ -14,6 +14,13 @@ import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
export interface BaseSelectorImage {
url: string;
mediaToken: string;
// Link to the thumbnail *template* URL of the image
// This is not a URL itself! It must be converted into a URL by replacing parameters.
thumbnailUrlTemplate?: string;
// Link to the full image URL of the full image asset
fullImageUrl?: string;
}
export type BaseImageSelectorProps = {
@@ -41,13 +48,13 @@ export const BaseImageSelector = ({
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
const handleImageSelect = (mediaToken: string) => {
// If already selected, deselect it
// Else if not selected and under max limit, select it
if (selectedGalleryImages.includes(id)) {
if (selectedGalleryImages.includes(mediaToken)) {
setSelectedGalleryImages([]);
} else {
setSelectedGalleryImages([id]);
setSelectedGalleryImages([mediaToken]);
}
};
@@ -56,12 +63,14 @@ export const BaseImageSelector = ({
if (selectedItems.length !== 1) {
return;
}
const item = selectedItems[0];
if (!item.fullImage) return;
if (!item.fullImage) {
return
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
};
sendImageEvent(referenceImage);
};
@@ -92,6 +101,7 @@ export const BaseImageSelector = ({
// Attempt to resolve the CDN URL for the uploaded image token; fallback to data URL
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({
@@ -102,6 +112,12 @@ export const BaseImageSelector = ({
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
// NB(bt,2025-10-09): I think `thumbnail_template` is wrong and that
// `maybe_thumbnail_template` is the correct API field, but upstream
// seems to imply otherwise. Let's simply check both keys for now.
thumbnailUrlTemplate =
result.data.media_links?.thumbnail_template ||
(result.data.media_links as any)?.maybe_thumbnail_template;
}
} catch (e) {
console.warn(
@@ -111,8 +127,10 @@ export const BaseImageSelector = ({
}
const referenceImage: BaseSelectorImage = {
url: finalUrl,
mediaToken,
url: finalUrl,
fullImageUrl: finalUrl,
thumbnailUrlTemplate: thumbnailUrlTemplate,
};
toast.success("Image uploaded successfully!");
@@ -71,6 +71,8 @@ export const HistoryStack = ({
({
url: editedImage.cdn_url,
mediaToken: editedImage.media_token,
thumbnailUrlTemplate: editedImage.maybe_thumbnail_template,
fullImageUrl: editedImage.cdn_url,
}) as BaseSelectorImage,
),
};
@@ -111,6 +113,18 @@ export const HistoryStack = ({
prevPendingCountRef.current = current;
}, [pendingPlaceholders.length]);
const getImageThumbnailSource = (image: BaseSelectorImage) => {
if (!!image.thumbnailUrlTemplate) {
// NB: this is the preferred way to populate the history stack thumbnails
return image.thumbnailUrlTemplate.replace("{WIDTH}", "256");
}
if (image.url.startsWith("data:")) {
return image.url;
}
console.warn("Using older image source for history stack image", image);
return `${image.url}?historystack+${sessionRandBuster.current}`;
};
return (
<div className="h-auto w-20 rounded-lg">
<div className="glass rounded-lg p-1.5">
@@ -202,13 +216,10 @@ export const HistoryStack = ({
onClick={() => handleSelectWithPreload(image)}
>
<img
src={
image.url.startsWith("data:")
? image.url
: `${image.url}?historystack+${sessionRandBuster.current}`
}
src={getImageThumbnailSource(image)}
alt=""
crossOrigin="anonymous"
// NB(bt,2025-10-09): We shouldn't need CORS here.
//crossOrigin="anonymous"
className="absolute inset-0 h-full w-full object-cover"
/>
<div
+1
View File
@@ -17,6 +17,7 @@
"build": "vite build",
"preview": "vite preview --port 8080",
"dev": "echo 'editor3d' && vite dev",
"production-preview": "echo 'editor3d dev with production preview' && vite build && vite preview --port 5173",
"staging-build": "vite build",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint playground/* .",
"typecheck": "tsc",
-3
View File
@@ -156,9 +156,6 @@
},
{
"path": "../../libs/api"
},
{
"path": "../../libs/api-enums"
}
]
}
-3
View File
@@ -6,9 +6,6 @@
{
"path": "../tauri-utils"
},
{
"path": "../api-enums"
},
{
"path": "./tsconfig.lib.json"
},
-3
View File
@@ -18,9 +18,6 @@
"references": [
{
"path": "../tauri-utils/tsconfig.lib.json"
},
{
"path": "../api-enums/tsconfig.lib.json"
}
],
"exclude": [
@@ -47,6 +47,9 @@ export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
// Thumbnail template is not a usable URL yet. It has some variables
// like `{WIDTH}` which must be replaced downstream.
thumbnailUrlTemplate?: string;
fullImage?: string | null;
createdAt: string;
mediaClass?: string;
@@ -266,6 +269,9 @@ export const GalleryModal = React.memo(
"{WIDTH}",
thumbnail_size.toString()
),
// TODO(bt): Thumbnail template URL may be wrong for videos and other asset types, but I'm
// trying to plumb it through for the inpainting editor, which is just images.
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
@@ -43,8 +43,9 @@ export const TEXT_TO_IMAGE_PAGE_MODEL_LIST : ModelList =
const set : Set<Model> = new Set();
set.add(IMAGE_MODELS_BY_ID.get("flux_pro_1_1")!); // Don't put Midjourney first!
IMAGE_MODELS
.filter((model) => !model.usesInpaintingMask) // We can't use masked inpainting models here
.filter((model) => model.id !== "flux_pro_kontext_max") // NB: Flux Pro Kontext Max requires an input image
.filter((model) => model.canTextToImage)
//.filter((model) => !model.usesInpaintingMask) // We can't use masked inpainting models here
//.filter((model) => model.id !== "flux_pro_kontext_max") // NB: Flux Pro Kontext Max requires an input image
.forEach((m) => set.add(m));
return Array.from(set);
})(),
@@ -27,6 +27,10 @@ export class ImageModel extends Model {
// Maximum number of image prompts that can be attached
readonly maxImagePromptCount: number;
// Whether the model can be used for text-to-image
// If true, it'll be displayed on the text-to-image page.
readonly canTextToImage: boolean;
constructor(args: {
id: string;
tauriId: string;
@@ -42,6 +46,7 @@ export class ImageModel extends Model {
usesInpaintingMask?: boolean;
canUseImagePrompt?: boolean;
maxImagePromptCount?: number;
canTextToImage?: boolean;
tags?: ModelTag[];
}) {
if (args.maxGenerationCount < 1) {
@@ -62,5 +67,6 @@ export class ImageModel extends Model {
this.usesInpaintingMask = args.usesInpaintingMask ?? false;
this.canUseImagePrompt = args.canUseImagePrompt ?? false;
this.maxImagePromptCount = Math.max(0, args.maxImagePromptCount ?? 1);
this.canTextToImage = args.canTextToImage === false ? false : true; // Default to true !
}
}
@@ -10,8 +10,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.Midjourney,
selectorName: "Midjourney",
selectorDescription: "Incredible style and quality",
selectorBadges: ["15 sec."],
selectorDescription: "Stunning style and quality",
selectorBadges: ["45 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: false,
@@ -24,8 +24,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux Pro 1.1 Ultra",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorDescription: "Higher quality model",
selectorBadges: ["35 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -39,7 +39,7 @@ export const IMAGE_MODELS: ImageModel[] = [
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux Pro 1.1",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorBadges: ["25 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -52,8 +52,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux 1 Dev",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorDescription: "Fast, but lower quality",
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -66,8 +66,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux 1 Schnell",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorDescription: "Fastest image gen, but lowest quality",
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -79,14 +79,15 @@ export const IMAGE_MODELS: ImageModel[] = [
fullName: "Gemini 2.5 Flash",
category: "image",
creator: ModelCreator.Google,
selectorName: "Gemini 2.5 Flash",
selectorDescription: "Ultra instructive model",
selectorBadges: ["35 sec."],
selectorName: "Nano Banana",
selectorDescription: "Incredibly instructive editing",
selectorBadges: ["25 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 1,
canEditImages: true,
canUseImagePrompt: true,
maxImagePromptCount: 6,
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
}),
new ImageModel({
@@ -96,8 +97,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.OpenAi,
selectorName: "GPT Image 1 (GPT-4o)",
selectorDescription: "Slow, ultra instructive model",
selectorBadges: ["45 sec."],
selectorDescription: "Slow, but super smart",
selectorBadges: ["60 sec."],
maxGenerationCount: 1,
defaultGenerationCount: 1,
tags: [ModelTag.InstructiveEdit],
@@ -117,6 +118,7 @@ export const IMAGE_MODELS: ImageModel[] = [
maxGenerationCount: 4,
defaultGenerationCount: 4,
canEditImages: true,
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
}),
new ImageModel({
@@ -132,6 +134,7 @@ export const IMAGE_MODELS: ImageModel[] = [
defaultGenerationCount: 1, // NB: Fal only allows one image for some reason!
canEditImages: true,
usesInpaintingMask: true,
canTextToImage: false,
}),
new ImageModel({
id: "flux_dev_juggernaut_inpaint",
@@ -140,12 +143,13 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux Dev Juggernaut",
selectorDescription: "Fast inpainting",
selectorBadges: ["15 sec."],
selectorDescription: "Fast inpainting, low quality",
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canEditImages: true,
usesInpaintingMask: true,
canTextToImage: false,
}),
];
+1
View File
@@ -21,6 +21,7 @@ export * from "./lib/openai/SetOpenAIApiKey.js";
export * from "./lib/providers/GetProviderOrder.js";
export * from "./lib/providers/Provider.js";
export * from "./lib/artcraft/task_queue/GetTaskQueue.js";
export * from "./lib/artcraft/task_queue/MarkTaskAsDismissed.js";
export * from "./lib/providers/SetProviderOrder.js";
export * from "./lib/settings/GetAppPreferences.js";
export * from "./lib/settings/UpdateAppPreference.js";
@@ -24,11 +24,32 @@ interface GetTaskQueueSuccess extends CommandResult {
export const GetTaskQueue = async () : Promise<GetTaskQueueResponse> => {
try {
let result = await invoke("get_task_queue_command") as GetTaskQueueSuccess;
return result.payload;
const result = await invoke("get_task_queue_command") as GetTaskQueueSuccess;
const tasks = result?.payload?.tasks || [];
// Convert timestamps to Date objects
const newTasks : TaskQueueItem[] = tasks.map((task) => {
return {
id: task.id,
task_status: task.task_status,
task_type: task.task_type,
model_type: task.model_type,
provider: task.provider,
provider_job_id: task.provider_job_id,
created_at: new Date(task.created_at),
updated_at: new Date(task.updated_at),
completed_at: task.completed_at ? new Date(task.completed_at) : undefined,
}
});
return {
tasks: newTasks,
};
} catch (error) {
throw error;
}
}
// Temporary just for testing -
(window as any).test_get_task_queue = GetTaskQueue;
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# This works on Linux and MacOS to launch the frontend dev server
root_dir=$(pwd)
frontend_path="${root_dir}/frontend"
echo "Running Artcraft Frontend in Production Build + Preview Mode... (It won't hot reload!)"
echo ""
echo "You'll need to launch the Rust dev server as a second script!"
echo ""
# Kill any process running on port 5741, which will block startup
if lsof -i tcp:5173 &>/dev/null; then
lsof -i tcp:5173 -t | xargs kill -9
echo "Killed process running on port 5173"
else
echo "No process running on port 5173"
fi
pushd "${frontend_path}" || exit
npm install --verbose
export VITE_ENVIRONMENT_TYPE="production"
nx production-preview editor3d
popd || exit