backport midjourney fixes from ArtCraft-X (#1953)

This commit is contained in:
Brandon Thomas
2026-09-14 12:45:24 -04:00
committed by GitHub
parent 5c7807add6
commit 584fdb7044
126 changed files with 5503 additions and 1566 deletions
@@ -271,7 +271,7 @@ function ItemActions({
if (!item.fullImage || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFileToDisk(item.fullImage, item.mediaClass);
await downloadMediaFileToDisk(item.fullImage, item.mediaClass, item.modelId);
} finally {
setIsDownloading(false);
}
@@ -43,34 +43,10 @@ import { useTabStore } from "~/pages/Stores/TabState";
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
audio: "mp3",
// "dimensional" is the deprecated pre-split 3D class; mesh/splat replace it.
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
function extensionForUrl(url: string, mediaClass?: string): string {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
if (match) return match[1].toLowerCase();
} catch {
// ignore — fall through to mediaClass default
}
if (mediaClass && EXT_BY_MEDIA_CLASS[mediaClass]) {
return EXT_BY_MEDIA_CLASS[mediaClass];
}
return "bin";
}
/** Download a media file, prompting for a location when configured to. */
export async function downloadMediaFileToDisk(url: string, mediaClass?: string) {
export async function downloadMediaFileToDisk(url: string, mediaClass?: string, model?: string) {
try {
const chosenPath = await promptDownloadLocationIfNeeded(url);
const chosenPath = await promptDownloadLocationIfNeeded(url, model);
if (chosenPath === null) {
// User dismissed the picker.
return;
@@ -78,7 +54,7 @@ export async function downloadMediaFileToDisk(url: string, mediaClass?: string)
if (typeof chosenPath === "string") {
await downloadUrlToPath(url, chosenPath);
} else {
await DownloadUrl(url);
await DownloadUrl(url, { model });
}
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
toast.success(`Downloaded 3D model`);
@@ -94,7 +70,7 @@ export async function downloadMediaFileToDisk(url: string, mediaClass?: string)
/**
* Batch download: prompt for a directory, then save each item as its own
* file (`artcraft-{token}.{ext}`). Progress and the outcome are surfaced via
* file using the preferred naming scheme. Progress and the outcome are surfaced via
* a single updating toast. Returns true when at least one file was saved
* (false on dismiss / nothing downloadable).
*/
@@ -120,11 +96,11 @@ export async function downloadMediaFilesToFolder(
id: toastId,
});
try {
const ext = extensionForUrl(item.fullImage, item.mediaClass);
await downloadUrlToPath(
item.fullImage,
`${dir}/artcraft-${item.id}.${ext}`,
);
await DownloadUrl(item.fullImage, {
directory: dir,
model: item.modelId,
batch_index: downloadable.length > 1 ? i + 1 : undefined,
});
} catch (error) {
console.error(">>> Failed to save file:", error);
failedCount++;
+8 -28
View File
@@ -1,7 +1,7 @@
import { download } from "@tauri-apps/plugin-upload";
import { downloadDir } from "@tauri-apps/api/path";
import { open, save } from "@tauri-apps/plugin-dialog";
import { DownloadUrl, GetDownloadPath } from "@storyteller/tauri-api";
const ASK_LOCATION_BEFORE_DOWNLOAD_KEY = "artcraft_ask_location_before_download";
@@ -27,17 +27,6 @@ export const setAskLocationBeforeDownload = (enabled: boolean): void => {
}
};
const deriveDownloadFilename = (url: string): string => {
try {
const urlObj = new URL(url);
const last = urlObj.pathname.split("/").pop();
if (last && last.length > 0) return last;
} catch {
// fall through
}
return "downloaded_file";
};
/**
* Prompts the user with a native save dialog if the
* "Ask location before download" setting is on.
@@ -49,10 +38,11 @@ const deriveDownloadFilename = (url: string): string => {
*/
export const promptDownloadLocationIfNeeded = async (
url: string,
model?: string,
): Promise<string | null | undefined> => {
if (!getAskLocationBeforeDownload()) return undefined;
const filename = deriveDownloadFilename(url);
const chosen = await save({ defaultPath: filename });
const { path } = await GetDownloadPath(url, { model });
const chosen = await save({ defaultPath: path });
return chosen ?? null;
};
@@ -70,29 +60,19 @@ export const downloadUrlToPath = async (url: string, path: string) => {
await download(url, path);
};
export const downloadFileFromUrl = async (url: string) => {
export const downloadFileFromUrl = async (url: string, _mediaClass?: string, model?: string) => {
console.log("GOT THE URL", url);
try {
const filename = deriveDownloadFilename(url);
let filePath: string;
const chosen = await promptDownloadLocationIfNeeded(url);
const chosen = await promptDownloadLocationIfNeeded(url, model);
if (chosen === null) {
// User dismissed the picker.
return;
}
if (typeof chosen === "string") {
filePath = chosen;
await download(url, chosen);
} else {
const downloadsPath = await downloadDir();
filePath = `${downloadsPath}/${filename}`;
await DownloadUrl(url, { model });
}
await download(url, filePath);
console.log(
`File downloaded and saved to ${filePath}`,
);
} catch (error) {
console.error("Error downloading file:", error);
throw error;
@@ -280,7 +280,7 @@ interface GalleryModalProps {
* (e.g. open the login modal on desktop, navigate to /login on web) instead
* of a Retry link. */
onLoginClick?: () => void;
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
onDownloadClicked?: (url: string, mediaClass?: string, model?: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
media_id: string | undefined,
@@ -60,7 +60,7 @@ interface LightboxModalProps {
additionalInfo?: ReactNode;
downloadUrl?: string;
mediaId?: string;
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
onDownloadClicked?: (url: string, mediaClass?: string, model?: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
media_id: string | undefined,
@@ -1183,7 +1183,7 @@ export function LightboxModal({
onClick={async (e) => {
e.stopPropagation();
gtagEvent("download_clicked");
await onDownloadClicked(actionUrl, mediaClass);
await onDownloadClicked(actionUrl, mediaClass, modelType ?? undefined);
}}
>
Download
@@ -0,0 +1,104 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { GenerationProvider } from "@storyteller/api-enums";
import { buildImageModelsFromListing } from "@storyteller/model-list";
import { useModelPickerStyleStore } from "@storyteller/ui-popover";
import { ClassyModelSelector } from "./classy-model-selector";
import { useClassyModelSelectorStore } from "./classy-model-selector-store";
import { ModelPage } from "./model-pages";
const PAGE = ModelPage.TextToImage;
const MODELS = buildImageModelsFromListing([], [
{ model: "nano_banana_pro" },
{ model: "gpt_image_1p5" },
{ model: "flux_dev" },
{ model: "seedream_4" },
{ model: "recraft_3" },
{ model: "imagen_4" },
{ model: "midjourney_7" },
{ model: "midjourney_8" },
]);
const INITIAL_MODEL = MODELS.find((model) => model.id === "nano_banana_pro")!;
const MIDJOURNEY_MODEL = MODELS.find((model) => model.id === "midjourney_8")!;
const ITEMS = MODELS.map((model) => ({ label: model.selectorName, model }));
beforeEach(() => {
vi.stubGlobal("ResizeObserver", class {
observe() {}
disconnect() {}
unobserve() {}
});
// jsdom has no layout; give the menu a visible rect so its viewport clamp
// and Headless UI visibility checks behave as they do in the app.
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
new DOMRect(100, 100, 280, 40),
);
useClassyModelSelectorStore.setState({
selectedModels: { [PAGE]: INITIAL_MODEL },
selectedProviders: { [PAGE]: { [MIDJOURNEY_MODEL.id]: GenerationProvider.Artcraft } },
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
useClassyModelSelectorStore.setState({ selectedModels: {}, selectedProviders: {} });
useModelPickerStyleStore.getState().setStyle("grouped");
});
it.each([
{ style: "grouped", provider: GenerationProvider.Midjourney, label: "Midjourney" },
{ style: "flat", provider: GenerationProvider.Midjourney, label: "Midjourney" },
{ style: "grouped", provider: GenerationProvider.Artcraft, label: "ArtCraft" },
] as const)(
"selects both Midjourney v8 and $label from the $style picker",
async ({ style, provider, label }) => {
if (provider === GenerationProvider.Artcraft) {
useClassyModelSelectorStore.getState().setSelectedProvider(PAGE, MIDJOURNEY_MODEL.id, GenerationProvider.Midjourney);
}
const user = await openProviderPicker(style);
// A real click includes the press and focus events that used to dismiss
// the grouped submenu before the provider's click handler could run.
await user.click(screen.getByRole("button", { name: new RegExp(label) }));
expect(useClassyModelSelectorStore.getState().selectedModels[PAGE]).toBe(MIDJOURNEY_MODEL);
expect(useClassyModelSelectorStore.getState().selectedProviders[PAGE]?.[MIDJOURNEY_MODEL.id])
.toBe(provider);
await waitFor(() => expect(screen.queryByText("Select Model")).toBeNull());
expect(screen.getByRole("button", { name: new RegExp(MIDJOURNEY_MODEL.selectorName) })).toBeTruthy();
},
);
it("still dismisses the grouped picker on an outside press without changing selection", async () => {
const user = await openProviderPicker("grouped");
await user.click(screen.getByRole("button", { name: "Outside" }));
await waitFor(() => expect(screen.queryByText("Select Model")).toBeNull());
expect(screen.queryByText("Select Provider")).toBeNull();
expect(useClassyModelSelectorStore.getState().selectedModels[PAGE]).toBe(INITIAL_MODEL);
expect(useClassyModelSelectorStore.getState().selectedProviders[PAGE]?.[MIDJOURNEY_MODEL.id])
.toBe(GenerationProvider.Artcraft);
});
async function openProviderPicker(style: "grouped" | "flat") {
const user = userEvent.setup();
useModelPickerStyleStore.getState().setStyle(style);
render(
<>
<ClassyModelSelector items={ITEMS} page={PAGE} variant="embedded" />
<button type="button">Outside</button>
</>,
);
await user.click(screen.getByRole("button", { name: new RegExp(INITIAL_MODEL.selectorName) }));
if (style === "grouped") {
await user.click(screen.getByText("Midjourney", { exact: true }));
}
await user.hover(await screen.findByText(MIDJOURNEY_MODEL.selectorName, { exact: true }));
await screen.findByRole("button", { name: /Midjourney/ });
return user;
}
@@ -25,6 +25,7 @@ interface PortalTooltipProps {
delay?: number;
className?: string;
onOpenChange?: (open: boolean) => void;
portalRef?: (element: HTMLDivElement | null) => void;
}
function PortalTooltip({
@@ -33,12 +34,12 @@ function PortalTooltip({
delay = 300,
className,
onOpenChange,
portalRef,
}: PortalTooltipProps) {
const [isShowing, setIsShowing] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [isHoveringTrigger, setIsHoveringTrigger] = useState(false);
const [isHoveringTooltip, setIsHoveringTooltip] = useState(false);
const showTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -122,7 +123,7 @@ function PortalTooltip({
isVisible &&
createPortal(
<div
ref={tooltipRef}
ref={portalRef}
// Body-portaled, so inside a Radix modal (promptbox focus mode)
// clicks here must not count as outside clicks on the dialog.
data-modal-outside-safe=""
@@ -441,6 +442,7 @@ function SubmenuFlyout({
const [canScrollDown, setCanScrollDown] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const childTooltipRefs = useRef(new Map<number, HTMLDivElement>());
const scrollRef = useRef<HTMLDivElement>(null);
const openTimerRef = useRef<NodeJS.Timeout | null>(null);
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -574,6 +576,12 @@ function SubmenuFlyout({
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
// Provider pickers are body portals, but still belong to this submenu.
// Keep them mounted through the press so their click can select a model
// and provider before closing the popover.
for (const tooltip of childTooltipRefs.current.values()) {
if (tooltip.contains(target)) return;
}
setIsShowing(false);
};
const onKey = (e: KeyboardEvent) => {
@@ -673,6 +681,13 @@ function SubmenuFlyout({
<div key={childIdx}>
<PortalTooltip
content={childTooltip}
portalRef={(element) => {
if (element) {
childTooltipRefs.current.set(childIdx, element);
} else {
childTooltipRefs.current.delete(childIdx);
}
}}
delay={child.tooltipDelayMs ?? 300}
className="min-w-48"
onOpenChange={(open) =>
@@ -8,7 +8,7 @@ import { GenerateIconButton } from "@storyteller/ui-button";
import { GenerateImage, GenerateImageRequest, commandErrorMessage } from "@storyteller/tauri-api";
import { ChevronDownIcon, ChevronUpIcon, MaximizeIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { ImageModel } from "@storyteller/model-list";
import { ImageModel, imageModelForProvider } from "@storyteller/model-list";
import { arrayMove } from "@dnd-kit/sortable";
import type { UploadMediaFn } from "@storyteller/api";
import {
@@ -68,7 +68,7 @@ export const PromptBoxImage = ({
useJobContext,
uploadImage,
onEnqueuePressed,
selectedModel,
selectedModel: catalogModel,
selectedProvider,
imageMediaId,
url,
@@ -76,6 +76,12 @@ export const PromptBoxImage = ({
modelSelector,
}: PromptBoxImageProps) => {
useSignals();
const selectedModel = useMemo(
() => imageModelForProvider(catalogModel, selectedProvider),
[catalogModel, selectedProvider],
);
const isDirectMidjourney = selectedProvider === GenerationProvider.Midjourney;
console.debug(
"Selected model and provider:",
@@ -101,7 +107,8 @@ export const PromptBoxImage = ({
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 storedGenerationCount = usePromptImageStore((s) => s.generationCount);
const generationCount = isDirectMidjourney ? 4 : storedGenerationCount;
const setGenerationCount = usePromptImageStore((s) => s.setGenerationCount);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
@@ -578,7 +585,7 @@ export const PromptBoxImage = ({
onClick={handleEnqueue}
disabled={!prompt.trim()}
loading={isEnqueueing}
credits={credits}
credits={isDirectMidjourney ? 0 : credits}
/>
</div>
</div>
@@ -0,0 +1,106 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DownloadsSettingsPane } from "./DownloadsSettingsPane";
const api = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
vi.mock("@storyteller/tauri-api", () => ({
GetAppPreferences: api.get,
UpdateAppPreferences: api.update,
DownloadDirectoryReveal: vi.fn(),
PreferenceName: {
AutoDownload: "auto_download",
PreferredDownloadDirectory: "preferred_download_directory",
PreferredDownloadFilename: "preferred_download_filename",
},
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
vi.mock("@storyteller/api", () => ({
getAskLocationBeforeDownload: () => false,
setAskLocationBeforeDownload: vi.fn(),
}));
beforeEach(() => {
vi.stubGlobal("ResizeObserver", class {
observe() {}
unobserve() {}
disconnect() {}
});
let preferences = {
preferred_download_directory: { custom: "/existing/downloads" },
auto_download: false,
preferred_download_filename: "artcraft_convention" as string | { custom_format: string },
};
api.get.mockImplementation(async () => ({ preferences: { ...preferences } }));
api.update.mockImplementation(async ({ preference, value }) => {
preferences = { ...preferences, [preference]: value };
return { success: true };
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("persists Auto Download using Tauri and keeps the existing directory", async () => {
const user = userEvent.setup();
render(<DownloadsSettingsPane />);
await screen.findByText("/existing/downloads");
const toggle = screen.getByRole("switch", { name: "Auto Download" });
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(screen.getByText("Downloads to your system the minute generations complete")).toBeTruthy();
await user.click(toggle);
await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("true"));
expect(api.update).toHaveBeenCalledWith({ preference: "auto_download", value: true });
cleanup();
render(<DownloadsSettingsPane />);
await screen.findByText("/existing/downloads");
expect(screen.getByRole("switch", { name: "Auto Download" }).getAttribute("aria-checked")).toBe("true");
});
it("saves a custom format explicitly, reloads it, and restores the app convention", async () => {
const user = userEvent.setup();
render(<DownloadsSettingsPane />);
await screen.findByText("/existing/downloads");
await user.click(screen.getByRole("button", { name: "Preferred download naming scheme" }));
await user.click(screen.getByRole("option", { name: "Custom format" }));
const input = screen.getByRole("textbox", { name: "Custom format" });
await user.clear(input);
// paste preserves braces, which userEvent.type otherwise treats as keys.
await user.paste("project_{model}_{YYYY}_{batch_index}");
expect(api.update).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Save format" }));
await waitFor(() => expect(api.update).toHaveBeenCalledWith({
preference: "preferred_download_filename",
value: { custom_format: "project_{model}_{YYYY}_{batch_index}" },
}));
cleanup();
render(<DownloadsSettingsPane />);
expect((await screen.findByRole("textbox", { name: "Custom format" }) as HTMLInputElement).value).toBe("project_{model}_{YYYY}_{batch_index}");
await user.click(screen.getByRole("button", { name: "Preferred download naming scheme" }));
await user.click(screen.getByRole("option", { name: "ArtCraft convention" }));
await waitFor(() => expect(screen.queryByRole("textbox", { name: "Custom format" })).toBeNull());
expect(api.update).toHaveBeenLastCalledWith({ preference: "preferred_download_filename", value: "artcraft_convention" });
});
it("rejects unsafe names and shows persistence failures without enabling Auto Download", async () => {
const user = userEvent.setup();
render(<DownloadsSettingsPane />);
await screen.findByText("/existing/downloads");
await user.click(screen.getByRole("button", { name: "Preferred download naming scheme" }));
await user.click(screen.getByRole("option", { name: "Custom format" }));
await user.clear(screen.getByRole("textbox", { name: "Custom format" }));
await user.paste("../escape");
await user.click(screen.getByRole("button", { name: "Save format" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(api.update).not.toHaveBeenCalled();
api.update.mockRejectedValueOnce(new Error("Cannot save preferences"));
await user.click(screen.getByRole("switch", { name: "Auto Download" }));
await screen.findByText("Error: Cannot save preferences");
expect(screen.getByRole("switch", { name: "Auto Download" }).getAttribute("aria-checked")).toBe("false");
});
@@ -0,0 +1,254 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import {
AppPreferencesPayload,
CustomDirectory,
DownloadDirectoryReveal,
GetAppPreferences,
PreferenceName,
PreferredDownloadFilename,
SystemDirectory,
UpdateAppPreferences,
} from "@storyteller/tauri-api";
import { open } from "@tauri-apps/plugin-dialog";
import { Label } from "@storyteller/ui-label";
import { Switch } from "@storyteller/ui-switch";
import { Select } from "@storyteller/ui-select";
import { FolderIcon, RotateCcwIcon, SearchIcon } from "lucide-react";
import {
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
} from "@storyteller/api";
const DEFAULT_CUSTOM_FORMAT = "{model}_{date}";
const FILENAME_OPTIONS = [
{ value: "artcraft_convention", label: "ArtCraft convention" },
{ value: "custom", label: "Custom format" },
];
export const DownloadsSettingsPane = () => {
const [preferences, setPreferences] = useState<
AppPreferencesPayload | undefined
>(undefined);
const [filenameMode, setFilenameMode] = useState("artcraft_convention");
const [customFormat, setCustomFormat] = useState(DEFAULT_CUSTOM_FORMAT);
const [formatError, setFormatError] = useState<string | null>(null);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] =
useState<boolean>(() => getAskLocationBeforeDownload());
const toggleAskLocationBeforeDownload = (enabled: boolean) => {
setAskLocationBeforeDownload(enabled);
setAskLocationBeforeDownloadState(enabled);
};
useEffect(() => {
const fetchData = async () => {
const prefs = await GetAppPreferences();
setPreferences(prefs.preferences);
const filename = prefs.preferences.preferred_download_filename;
if (filename && typeof filename === "object") {
setFilenameMode("custom");
setCustomFormat(filename.custom_format);
}
};
fetchData().catch((error) => setSettingsError(String(error)));
}, []);
// NB: This might be a complex type.
const outerDownloadObject = preferences?.preferred_download_directory || {};
const downloadDirectory =
"custom" in outerDownloadObject
? (outerDownloadObject.custom as string)
: "";
const currentDownloadLabel =
"system" in outerDownloadObject
? "System Download Directory"
: downloadDirectory;
const reloadPreferences = async () => {
const prefs = await GetAppPreferences();
setPreferences(prefs.preferences);
};
const openDirectoryPicker = async () => {
let directory = await open({
multiple: false,
directory: true,
defaultPath: downloadDirectory || undefined,
});
if (directory === null) {
return; // User dismissed the dialog choice
}
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
custom: directory,
} as CustomDirectory,
});
await reloadPreferences();
};
const clearDirectory = async () => {
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
system: "downloads",
} as SystemDirectory,
});
await reloadPreferences();
};
const showDirectory = async () => {
await DownloadDirectoryReveal();
};
const toggleAutoDownload = async (enabled: boolean) => {
setSaving(true);
setSettingsError(null);
try {
await UpdateAppPreferences({ preference: PreferenceName.AutoDownload, value: enabled });
await reloadPreferences();
} catch (error) {
setSettingsError(String(error));
} finally {
setSaving(false);
}
};
const saveFilenamePreference = async (value: PreferredDownloadFilename) => {
setSaving(true);
setFormatError(null);
try {
await UpdateAppPreferences({ preference: PreferenceName.PreferredDownloadFilename, value });
await reloadPreferences();
setFilenameMode(typeof value === "string" ? value : "custom");
} catch (error) {
setFormatError(String(error));
} finally {
setSaving(false);
}
};
const saveCustomFormat = async () => {
const format = customFormat.trim();
if (!format) {
setFormatError("Format cannot be empty");
return;
}
if (/[/\\'"`%<>|:*?\x00-\x1f\x7f]/.test(format) || format.includes("..")) {
setFormatError("Format cannot contain slashes, quotes, or other unsafe characters");
return;
}
await saveFilenamePreference({ custom_format: format });
};
return (
<div className="space-y-4 text-base-fg">
{settingsError && <p role="alert" className="text-red-400">{settingsError}</p>}
<div className="space-y-2">
<Label htmlFor="download-path">Default Download Directory</Label>
<p className="opacity-80">
This is where downloads are placed after downloading. The current path
is:
</p>
<div className="py-1.5 px-2 rounded-md mt-1 bg-ui-panel border border-ui-panel-border text-base-fg">
<pre>{currentDownloadLabel}</pre>
</div>
</div>
<div className="flex gap-2">
<Button variant="primary" onClick={openDirectoryPicker}>
<FolderIcon />
Choose Directory
</Button>
<Button variant="destructive" onClick={clearDirectory}>
<RotateCcwIcon />
Use Default
</Button>
<Button variant="secondary" onClick={showDirectory}>
<SearchIcon />
Show Directory
</Button>
</div>
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<Label htmlFor="auto-download">Auto Download</Label>
<p className="text-xs opacity-70">
Downloads to your system the minute generations complete
</p>
</div>
<Switch
id="auto-download"
label="Auto Download"
enabled={preferences?.auto_download ?? false}
setEnabled={toggleAutoDownload}
disabled={!preferences || saving}
/>
</div>
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<Label htmlFor="ask-location-before-download">
Ask location before download
</Label>
<p className="text-xs opacity-70">
When on, a system file picker appears every time you download from
the lightbox or anywhere in the app, letting you choose the save
location for that file. When off, downloads go straight to the
default download directory above.
</p>
</div>
<Switch
id="ask-location-before-download"
label="Ask location before download"
enabled={askLocationBeforeDownload}
setEnabled={toggleAskLocationBeforeDownload}
/>
</div>
<fieldset disabled={!preferences || saving} className="flex flex-col gap-2 border-0 p-0">
<Label htmlFor="download-filename-mode">Preferred download naming scheme</Label>
<p className="text-xs opacity-70">
{"The ArtCraft convention is {model}_{date}.{ext}, with a batch number when a generation produces several files."}
</p>
<div className="max-w-xs">
<Select
id="download-filename-mode"
options={FILENAME_OPTIONS}
value={filenameMode}
onChange={(value) => {
setFormatError(null);
if (value === "custom") {
setFilenameMode("custom");
} else {
saveFilenamePreference("artcraft_convention");
}
}}
/>
</div>
{filenameMode === "custom" && (
<>
<Label htmlFor="download-filename-format">Custom format</Label>
<div className="flex flex-wrap items-center gap-2">
<input
id="download-filename-format"
type="text"
value={customFormat}
onChange={(event) => setCustomFormat(event.target.value)}
placeholder={DEFAULT_CUSTOM_FORMAT}
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-xs"
/>
<Button variant="secondary" onClick={saveCustomFormat}>Save format</Button>
</div>
<p className="text-xs opacity-70">
{"Tokens: {model}, {date}, {YYYY}, {YY}, {MM}, {DD}, {HH}, {mm}, {SS}, {batch_index}. The file extension is added automatically. Batch numbers are added automatically if omitted."}
</p>
<p className="text-xs opacity-70">Changes apply after you click Save format.</p>
</>
)}
{formatError && <p role="alert" className="text-xs text-red-400">{formatError}</p>}
</fieldset>
</div>
);
};
@@ -1,150 +1,23 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import {
AppPreferencesPayload,
CustomDirectory,
GetAppPreferences,
SystemDirectory,
} from "@storyteller/tauri-api";
import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api";
import { open } from "@tauri-apps/plugin-dialog";
import { Label } from "@storyteller/ui-label";
import { Switch } from "@storyteller/ui-switch";
import { DownloadDirectoryReveal } from "@storyteller/tauri-api";
import { FolderIcon, RotateCcwIcon, SearchIcon } from "lucide-react";
import { useEnterToGenerateStore } from "@storyteller/ui-promptbox";
import { useModelPickerStyleStore } from "@storyteller/ui-popover";
import { useKeybindsStore } from "@storyteller/keybinds";
import {
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
} from "@storyteller/api";
interface MiscSettingsPaneProps {}
export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
const [preferences, setPreferences] = useState<
AppPreferencesPayload | undefined
>(undefined);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] =
useState<boolean>(() => getAskLocationBeforeDownload());
const modelPickerStyle = useModelPickerStyleStore((s) => s.style);
const setModelPickerStyle = useModelPickerStyleStore((s) => s.setStyle);
const cheatsheetSticky = useKeybindsStore((s) => s.cheatsheetSticky);
const setCheatsheetSticky = useKeybindsStore((s) => s.setCheatsheetSticky);
const toggleAskLocationBeforeDownload = (enabled: boolean) => {
setAskLocationBeforeDownload(enabled);
setAskLocationBeforeDownloadState(enabled);
};
useEffect(() => {
const fetchData = async () => {
const prefs = await GetAppPreferences();
console.log("prefs", prefs);
setPreferences(prefs.preferences);
};
fetchData();
}, []);
// NB: This might be a complex type.
const outerDownloadObject = preferences?.preferred_download_directory || {};
const downloadDirectory =
"custom" in outerDownloadObject
? (outerDownloadObject.custom as string)
: "";
const currentDownloadLabel =
"system" in outerDownloadObject
? "System Download Directory"
: downloadDirectory;
const reloadPreferences = async () => {
const prefs = await GetAppPreferences();
console.log("prefs", prefs);
setPreferences(prefs.preferences);
};
const openDirectoryPicker = async () => {
let directory = await open({
multiple: false,
directory: true,
defaultPath: downloadDirectory || undefined,
});
if (directory === null) {
return; // User dismissed the dialog choice
}
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
custom: directory,
} as CustomDirectory,
});
await reloadPreferences();
};
const clearDirectory = async () => {
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
system: "downloads",
} as SystemDirectory,
});
await reloadPreferences();
};
const showDirectory = async () => {
await DownloadDirectoryReveal();
};
return (
<div className="space-y-4 text-base-fg">
<div className="space-y-2">
<Label htmlFor="download-path">Default Download Directory</Label>
<p className="opacity-80">
This is where downloads are placed after downloading. The current path
is:
</p>
<div className="py-1.5 px-2 rounded-md mt-1 bg-ui-panel border border-ui-panel-border text-base-fg">
<pre>{currentDownloadLabel}</pre>
</div>
</div>
<div className="flex gap-2">
<Button variant="primary" onClick={openDirectoryPicker}>
<FolderIcon />
Choose Directory
</Button>
<Button variant="destructive" onClick={clearDirectory}>
<RotateCcwIcon />
Use Default
</Button>
<Button variant="secondary" onClick={showDirectory}>
<SearchIcon />
Show Directory
</Button>
</div>
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<Label htmlFor="ask-location-before-download">
Ask location before download
</Label>
<p className="text-xs opacity-70">
When on, a system file picker appears every time you download from
the lightbox or anywhere in the app, letting you choose the save
location for that file. When off, downloads go straight to the
default download directory above.
</p>
</div>
<Switch
enabled={askLocationBeforeDownload}
setEnabled={toggleAskLocationBeforeDownload}
/>
</div>
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<Label htmlFor="enter-to-generate">Enter to generate</Label>
@@ -1,10 +1,11 @@
import { Modal } from "@storyteller/ui-modal";
import { useEffect, useState } from "react";
import { CreditCardIcon, FlaskConicalIcon, InfoIcon, KeyboardIcon, PaletteIcon, SettingsIcon, UserIcon, Volume2Icon } from "lucide-react";
import { CreditCardIcon, DownloadIcon, FlaskConicalIcon, InfoIcon, KeyboardIcon, PaletteIcon, SettingsIcon, UserIcon, Volume2Icon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { twMerge } from "tailwind-merge";
import { KeybindsSettings } from "@storyteller/keybinds";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
import { DownloadsSettingsPane } from "./panes/DownloadsSettingsPane";
import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
@@ -27,6 +28,7 @@ interface SettingsModalProps {
type SettingsSection =
| "general"
| "downloads"
| "appearance"
| "keybinds"
| "accounts"
@@ -66,6 +68,7 @@ export const SettingsModal = ({
const sections = [
{ id: "general" as const, label: "General", icon: SettingsIcon },
{ id: "downloads" as const, label: "Downloads", icon: DownloadIcon },
{ id: "accounts" as const, label: "Accounts", icon: UserIcon },
{ id: "billing" as const, label: "Plan & Credits", icon: CreditCardIcon },
@@ -96,6 +99,8 @@ export const SettingsModal = ({
return <AudioSettingsPane />;
case "general":
return <MiscSettingsPane />;
case "downloads":
return <DownloadsSettingsPane />;
case "accounts":
return (
<AccountSettingsPane
@@ -5,13 +5,16 @@ import { Fragment } from "react";
interface SwitchProps {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
id?: string;
label?: string;
disabled?: boolean;
className?: string;
offClassName?: string;
}
export function Switch({ enabled, setEnabled, className, offClassName }: SwitchProps) {
export function Switch({ enabled, setEnabled, id, label = "Enable notifications", disabled = false, className, offClassName }: SwitchProps) {
return (
<HeadlessSwitch checked={enabled} onChange={setEnabled} as={Fragment}>
<HeadlessSwitch id={id} checked={enabled} onChange={setEnabled} disabled={disabled} as={Fragment}>
{({ checked, disabled }) => (
<button
className={clsx(
@@ -21,7 +24,7 @@ export function Switch({ enabled, setEnabled, className, offClassName }: SwitchP
className,
)}
>
<span className="sr-only">Enable notifications</span>
<span className="sr-only">{label}</span>
<span
className={clsx(
"size-4 rounded-full bg-white transition",
+2
View File
@@ -27,3 +27,5 @@ export * from "./lib/lists/Object3DModels.js";
export * from "./lib/loader/buildModelsFromListing.js";
export * from "./lib/classes/videoOptions.js";
export { imageModelForProvider } from "./lib/classes/imageModelForProvider.js";
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { expect, it } from "vitest";
import { GenerationProvider } from "@storyteller/api-enums";
import { buildImageModelsFromListing } from "../loader/buildModelsFromListing.js";
import { imageModelForProvider } from "./imageModelForProvider.js";
it("switches Midjourney capabilities without changing the ArtCraft catalog model", () => {
const [model] = buildImageModelsFromListing([], [{
model: "midjourney_8", batch_size_options: [1, 2, 4], batch_size_default: 1,
image_refs_supported: true, image_refs_max: 4,
resolution_options: ["two_k"], quality_options: ["high"],
}]);
const direct = imageModelForProvider(model, GenerationProvider.Midjourney)!;
expect(direct.getProviders()).toEqual([GenerationProvider.Artcraft, GenerationProvider.Midjourney]);
expect(direct.predefinedGenerationCounts).toEqual([4]);
expect(direct.defaultGenerationCount).toBe(4);
expect(direct.isValidGenerationCount(1)).toBe(false);
expect(direct.canUseImagePrompt).toBe(false);
expect(direct.supportsNewResolution()).toBe(false);
expect(direct.supportsQuality()).toBe(false);
expect(imageModelForProvider(model, GenerationProvider.Artcraft)).toBe(model);
expect(model.canUseImagePrompt).toBe(true);
expect(model.predefinedGenerationCounts).toEqual([1, 2, 4]);
});
@@ -0,0 +1,27 @@
import { GenerationProvider } from "@storyteller/api-enums";
import { ImageModel } from "./ImageModel.js";
/** Capabilities of the user's own Midjourney session, independent of the
* ArtCraft backend's capabilities for the same models. */
export function imageModelForProvider(
model: ImageModel | undefined,
provider: GenerationProvider | undefined,
): ImageModel | undefined {
if (!model || provider !== GenerationProvider.Midjourney) return model;
if (!["midjourney", "midjourney_7", "midjourney_7_niji", "midjourney_8"].includes(model.tauriId)) return model;
return new ImageModel({
...model,
providers: model.getProviders(),
defaultGenerationCount: 4,
maxGenerationCount: 4,
predefinedGenerationCounts: [4],
canUseImagePrompt: false,
canEditImages: false,
maxImagePromptCount: 0,
canChangeResolution: false,
resolutions: [],
qualityOptions: [],
defaultResolution: undefined,
defaultQuality: undefined,
});
}
@@ -41,7 +41,7 @@ export const IMAGE_MODELS: ImageModel[] = [
fullName: "Midjourney",
category: "image",
creator: ModelCreator.Midjourney,
providers: [GenerationProvider.Midjourney],
providers: [GenerationProvider.Midjourney, GenerationProvider.Artcraft],
selectorName: "Midjourney",
selectorDescription: "Stunning style and quality",
selectorBadges: ["45 sec."],
@@ -133,3 +133,14 @@ describe("API video options", () => {
expect(resolveVideoDuration(model, 30, false)).toBe(12);
});
});
it("offers ArtCraft and direct Midjourney only for supported Midjourney models", () => {
const models = buildImageModelsFromListing([], [
{ model: "midjourney_7" }, { model: "midjourney_7_niji" },
{ model: "midjourney_8" }, { model: "future_image" },
]);
for (const model of models.slice(0, 3)) {
expect(model.getProviders()).toEqual([GenerationProvider.Artcraft, GenerationProvider.Midjourney]);
}
expect(models[3].getProviders()).toEqual([GenerationProvider.Artcraft]);
});
@@ -1,3 +1,4 @@
import { GenerationProvider } from "@storyteller/api-enums";
// Builds the picker model lists FROM the backend omni listing (the Tauri
// command response). Membership + order come from the backend, and every
// capability the API expresses is mapped 1:1 — the static overlay lists
@@ -193,7 +194,9 @@ const mergedImageModel = (
canEditAngles: o?.canEditAngles ?? false,
// Desktop-native provider knowledge.
providers: o?.getProviders(),
providers: ["midjourney_7", "midjourney_7_niji", "midjourney_8"].includes(tauriId)
? [GenerationProvider.Artcraft, GenerationProvider.Midjourney]
: o?.getProviders(),
// Capabilities — served by the API, overlay only as a transitional
// fallback where the backend config leaves a field unset.
@@ -8,11 +8,21 @@ export interface DownloadUrlSuccess extends CommandResult {
export interface DownloadUrlPayload {
}
export const DownloadUrl = async (url: string) : Promise<DownloadUrlSuccess> => {
export interface DownloadOptions {
model?: string;
batch_index?: number;
directory?: string;
}
export const GetDownloadPath = (url: string, options: DownloadOptions = {}): Promise<{ filename: string; path: string }> =>
invoke("get_download_path_command", { request: { url, ...options } });
export const DownloadUrl = async (url: string, options: DownloadOptions = {}) : Promise<DownloadUrlSuccess> => {
try {
return await invoke("download_url_command", {
request: {
url: url
url,
...options,
}
}) as DownloadUrlSuccess;
} catch (error) {
@@ -69,6 +69,7 @@ export enum GenerateImageErrorType {
ServerError = "server_error",
NeedsStorytellerCredentials = "needs_storyteller_credentials",
NeedsGrokCredentials = "needs_grok_credentials",
NeedsMidjourneyCredentials = "needs_midjourney_credentials",
BillingIssue = "billing_issue",
}
@@ -70,3 +70,9 @@ it("preserves reference duration hints in video pricing", async () => {
request,
});
});
it.each(["artcraft", "midjourney"])("passes an explicit %s provider for Midjourney generation", async (provider) => {
const request = { model: "midjourney_8", provider, batch_size: 4, prompt: "sailboat" };
await GenerateImage(request as Parameters<typeof GenerateImage>[0]);
expect(invoke).toHaveBeenCalledWith("generate_image_command", { request });
});
@@ -7,6 +7,8 @@ export interface GetAppPreferencesResult {
export interface AppPreferencesPayload {
// Preferred download directory
preferred_download_directory: PreferredDownloadDirectory,
preferred_download_filename: PreferredDownloadFilename,
auto_download: boolean,
// Play sounds on events.
play_sounds: boolean,
@@ -23,6 +25,12 @@ export interface AppPreferencesPayload {
export type PreferredDownloadDirectory = SystemDirectory | CustomDirectory;
export type PreferredDownloadFilename = "artcraft_convention" | CustomFilenameFormat;
export interface CustomFilenameFormat {
custom_format: string;
}
export interface SystemDirectory {
// If the directory is a system directory.
system: string,
@@ -1,14 +1,16 @@
import { invoke } from "@tauri-apps/api/core";
import { PreferredDownloadDirectory } from "./GetAppPreferences";
import { PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences";
export interface UpdateAppPreferencesRequest {
preference: PreferenceName,
value: undefined | string | boolean | PreferredDownloadDirectory,
value: undefined | string | boolean | PreferredDownloadDirectory | PreferredDownloadFilename,
}
export enum PreferenceName {
PreferredDownloadDirectory = "preferred_download_directory",
PreferredDownloadFilename = "preferred_download_filename",
AutoDownload = "auto_download",
PlaySounds = "play_sounds",
DeleteFileSound = "delete_file_sound",
EnqueueSuccessSound = "enqueue_success_sound",