mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
backport midjourney fixes from ArtCraft-X (#1953)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user