diff --git a/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx index 6c4547c7e5..231b89a525 100644 --- a/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageDraw/PageDraw.tsx @@ -16,9 +16,11 @@ import { CANVAS_2D_PAGE_MODEL_LIST, ClassyModelSelector, ModelPage, + ProviderSelector, + PROVIDER_LOOKUP_BY_PAGE, + useSelectedImageModel, } from "@storyteller/ui-model-selector"; import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api"; -import { getSelectedImageModel } from "@storyteller/ui-model-selector"; import { TutorialModalButton } from "@storyteller/ui-tutorial-modal"; const PAGE_ID: ModelPage = ModelPage.Canvas2D; @@ -57,7 +59,7 @@ const PageDraw = () => { const store = useSceneStore(); const selectedImageModel: ImageModel | undefined = - getSelectedImageModel(PAGE_ID); + useSelectedImageModel(PAGE_ID); useDeleteHotkeys({ onDelete: store.deleteSelectedItems }); useUndoRedoHotkeys({ undo: store.undo, redo: store.redo }); @@ -441,7 +443,7 @@ const PageDraw = () => { /> -
+
{ showIconsInList triggerLabel="Model" /> +
diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx index 36aeacfd35..c1f276e7a5 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx @@ -13,9 +13,11 @@ import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector"; import DrawToolControlBar from "./DrawToolControlBar"; import { ClassyModelSelector, - getSelectedImageModel, + useSelectedImageModel, IMAGE_EDITOR_PAGE_MODEL_LIST, ModelPage, + ProviderSelector, + PROVIDER_LOOKUP_BY_PAGE, } from "@storyteller/ui-model-selector"; import { ImageModel } from "@storyteller/model-list"; import { HistoryStack, ImageBundle } from "./HistoryStack"; @@ -27,7 +29,7 @@ const PageEdit = () => { //useStateSceneLoader(); const selectedImageModel: ImageModel | undefined = - getSelectedImageModel(PAGE_ID); + useSelectedImageModel(PAGE_ID); // State for canvas dimensions const canvasWidth = useRef(1024); @@ -463,7 +465,7 @@ const PageEdit = () => { />
-
+
{ showIconsInList triggerLabel="Model" /> +
diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx index 80ea46ac09..bafea3475b 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx @@ -69,8 +69,10 @@ import { import { STAGE_3D_PAGE_MODEL_LIST, ModelPage, - getSelectedImageModel, + useSelectedImageModel, ClassyModelSelector, + ProviderSelector, + PROVIDER_LOOKUP_BY_PAGE, } from "@storyteller/ui-model-selector"; import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal"; import PageDraw from "../PageDraw/PageDraw"; @@ -106,7 +108,7 @@ export const PageEditor = () => { }, []); const selectedImageModel: ImageModel | undefined = - getSelectedImageModel(PAGE_ID); + useSelectedImageModel(PAGE_ID); const height = dndTimelineHeight.value > -1 @@ -541,7 +543,7 @@ export const PageEditor = () => { message={editorLoader.value.message} /> -
+
{ showIconsInList triggerLabel="Model" /> +
diff --git a/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx b/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx index 2aa7abcb83..61b67207fa 100644 --- a/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageImage/TextToImage.tsx @@ -7,7 +7,9 @@ import { TEXT_TO_IMAGE_PAGE_MODEL_LIST, ModelPage, ClassyModelSelector, - getSelectedImageModel, + useSelectedImageModel, + ProviderSelector, + PROVIDER_LOOKUP_BY_PAGE, } from "@storyteller/ui-model-selector"; import { ImageModel } from "@storyteller/model-list"; interface TextToImageProps { @@ -38,7 +40,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { const [promptHeight, setPromptHeight] = useState(138); const selectedImageModel: ImageModel | undefined = - getSelectedImageModel(PAGE_ID); + useSelectedImageModel(PAGE_ID); const jobContext: JobContextType = { jobTokens: [], @@ -213,7 +215,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { {!showPromptAtBottom && } -
+
{ showIconsInList triggerLabel="Model" /> +
diff --git a/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx b/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx index 6c2ce16d16..295646c4fe 100644 --- a/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageVideo/ImageToVideo.tsx @@ -6,7 +6,9 @@ import { ClassyModelSelector, IMAGE_TO_VIDEO_PAGE_MODEL_LIST, ModelPage, - getSelectedVideoModel, + useSelectedVideoModel, + ProviderSelector, + PROVIDER_LOOKUP_BY_PAGE, } from "@storyteller/ui-model-selector"; import { VideoModel } from "@storyteller/model-list"; import { animated, useSpring } from "@react-spring/web"; @@ -38,7 +40,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => { const [promptHeight, setPromptHeight] = useState(138); const selectedVideoModel: VideoModel | undefined = - getSelectedVideoModel(PAGE_ID); + useSelectedVideoModel(PAGE_ID); const jobContext: JobContextType = { jobTokens: [], @@ -201,7 +203,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => { {/* } */} -
+
{ showIconsInList triggerLabel="Model" /> +
diff --git a/frontend/apps/editor3d/app/src/styles/base.css b/frontend/apps/editor3d/app/src/styles/base.css index 4a1c187a19..3c9a15153f 100644 --- a/frontend/apps/editor3d/app/src/styles/base.css +++ b/frontend/apps/editor3d/app/src/styles/base.css @@ -208,6 +208,14 @@ img { pointer-events: none; } +/* Icon auto-contrast: invert only in dark themes */ +.icon-auto-contrast { + filter: invert(1); +} +.theme-light .icon-auto-contrast { + filter: none; +} + /* Hide native scrollbars while preserving scrollability */ .scrollbar-hidden { -ms-overflow-style: none; /* IE and Edge */ diff --git a/frontend/libs/components/model-selector/src/index.ts b/frontend/libs/components/model-selector/src/index.ts index f72bcedf80..7d5762771b 100644 --- a/frontend/libs/components/model-selector/src/index.ts +++ b/frontend/libs/components/model-selector/src/index.ts @@ -2,4 +2,6 @@ export * from "./lib/model-selectors-for-pages"; export * from "./lib/model-pages"; export * from "./lib/classy-model-selector"; export * from "./lib/classy-model-selector-store"; - +export * from "./lib/provider-selector"; +export * from "./lib/provider-icons"; +export * from "./lib/provider-lookup"; diff --git a/frontend/libs/components/model-selector/src/lib/classy-model-selector-store.ts b/frontend/libs/components/model-selector/src/lib/classy-model-selector-store.ts index 75886413d5..35a2a89636 100644 --- a/frontend/libs/components/model-selector/src/lib/classy-model-selector-store.ts +++ b/frontend/libs/components/model-selector/src/lib/classy-model-selector-store.ts @@ -1,25 +1,47 @@ import { create } from "zustand"; import { ModelPage } from "./model-pages"; import { ImageModel, Model, VideoModel } from "@storyteller/model-list"; +import { Provider } from "@storyteller/tauri-api"; interface ClassyModelSelectorState { selectedModels: { [page in ModelPage]?: Model }; + selectedProviders: { [page in ModelPage]?: { [modelId: string]: Provider } }; setSelectedModel: (page: ModelPage, model: Model) => void; + setSelectedProvider: ( + page: ModelPage, + modelId: string, + provider: Provider + ) => void; } -export const useClassyModelSelectorStore = create((set) => ({ - selectedModels: {}, - setSelectedModel: (page, model) => - set((state) => ({ - selectedModels: { - ...state.selectedModels, - [page]: model, - }, - })), -})); +export const useClassyModelSelectorStore = create( + (set) => ({ + selectedModels: {}, + selectedProviders: {}, + setSelectedModel: (page, model) => + set((state) => ({ + selectedModels: { + ...state.selectedModels, + [page]: model, + }, + })), + setSelectedProvider: (page, modelId, provider) => + set((state) => ({ + selectedProviders: { + ...state.selectedProviders, + [page]: { + ...(state.selectedProviders[page] ?? {}), + [modelId]: provider, + }, + }, + })), + }) +); -export const getSelectedImageModel = (page: ModelPage) : ImageModel | undefined => { - const { selectedModels } = useClassyModelSelectorStore(); +export const getSelectedImageModel = ( + page: ModelPage +): ImageModel | undefined => { + const { selectedModels } = useClassyModelSelectorStore.getState(); const maybeModel = selectedModels[page]; if (!maybeModel) { return undefined; @@ -32,8 +54,10 @@ export const getSelectedImageModel = (page: ModelPage) : ImageModel | undefined return undefined; }; -export const getSelectedVideoModel = (page: ModelPage) : VideoModel | undefined => { - const { selectedModels } = useClassyModelSelectorStore(); +export const getSelectedVideoModel = ( + page: ModelPage +): VideoModel | undefined => { + const { selectedModels } = useClassyModelSelectorStore.getState(); const maybeModel = selectedModels[page]; if (!maybeModel) { return undefined; @@ -45,3 +69,45 @@ export const getSelectedVideoModel = (page: ModelPage) : VideoModel | undefined } return maybeModel as VideoModel; }; + +export const getSelectedProviderForModel = ( + page: ModelPage, + modelId: string +): Provider | undefined => { + const { selectedProviders } = useClassyModelSelectorStore.getState(); + const byPage = selectedProviders[page]; + if (!byPage) return undefined; + return byPage[modelId]; +}; + +// Reactive hooks for UI subscriptions +export const useSelectedModel = (page: ModelPage): Model | undefined => + useClassyModelSelectorStore((s) => s.selectedModels[page]); + +export const useSelectedImageModel = ( + page: ModelPage +): ImageModel | undefined => { + const maybeModel = useSelectedModel(page); + if (!maybeModel) return undefined; + return maybeModel.kind === "image_model" + ? (maybeModel as ImageModel) + : undefined; +}; + +export const useSelectedVideoModel = ( + page: ModelPage +): VideoModel | undefined => { + const maybeModel = useSelectedModel(page); + if (!maybeModel) return undefined; + return maybeModel.kind === "video_model" + ? (maybeModel as VideoModel) + : undefined; +}; + +export const useSelectedProviderForModel = ( + page: ModelPage, + modelId: string | undefined +): Provider | undefined => + useClassyModelSelectorStore((s) => + modelId ? s.selectedProviders[page]?.[modelId] : undefined + ); diff --git a/frontend/libs/components/model-selector/src/lib/provider-icons.tsx b/frontend/libs/components/model-selector/src/lib/provider-icons.tsx new file mode 100644 index 0000000000..9d77d90141 --- /dev/null +++ b/frontend/libs/components/model-selector/src/lib/provider-icons.tsx @@ -0,0 +1,37 @@ +import { getCreatorIcon, ModelCreator } from "@storyteller/model-list"; +import { Provider } from "@storyteller/tauri-api"; +import { ReactNode } from "react"; + +const PROVIDER_TO_CREATOR: Partial> = { + [Provider.ArtCraft]: ModelCreator.ArtCraft, + [Provider.Fal]: ModelCreator.Fal, + [Provider.Sora]: ModelCreator.OpenAi, +}; + +export const getProviderIcon = ( + provider: Provider, + className = "h-4 w-4 icon-auto-contrast" +): ReactNode => { + const creator = PROVIDER_TO_CREATOR[provider]; + if (creator) return getCreatorIcon(creator, className); + return ( + generic logo + ); +}; + +export const getProviderDisplayName = (provider: Provider): string => { + switch (provider) { + case Provider.ArtCraft: + return "ArtCraft"; + case Provider.Fal: + return "Fal"; + case Provider.Sora: + return "Sora / ChatGPT"; + default: + return provider; + } +}; diff --git a/frontend/libs/components/model-selector/src/lib/provider-lookup.ts b/frontend/libs/components/model-selector/src/lib/provider-lookup.ts new file mode 100644 index 0000000000..6ca3b855d8 --- /dev/null +++ b/frontend/libs/components/model-selector/src/lib/provider-lookup.ts @@ -0,0 +1,24 @@ +import { ModelPage } from "./model-pages"; +import { Provider } from "@storyteller/tauri-api"; + +export type ProvidersByModel = Partial>; + +// Centralized lookup: page (product/tab) → modelId → allowed providers +export const PROVIDER_LOOKUP_BY_PAGE: Record = { + [ModelPage.TextToImage]: { + midjourney: [Provider.ArtCraft], + flux_pro_1_1: [Provider.ArtCraft, Provider.Fal], + gpt_image_1: [Provider.Sora], + }, + [ModelPage.ImageToVideo]: {}, + [ModelPage.Canvas2D]: { + gpt_image_1: [Provider.Sora], + flux_pro_1_1: [Provider.ArtCraft, Provider.Fal], + midjourney: [Provider.ArtCraft], + }, + [ModelPage.Stage3D]: {}, + [ModelPage.ImageEditor]: { + gpt_image_1: [Provider.Sora], + flux_pro_1_1: [Provider.ArtCraft, Provider.Fal], + }, +}; diff --git a/frontend/libs/components/model-selector/src/lib/provider-selector.tsx b/frontend/libs/components/model-selector/src/lib/provider-selector.tsx new file mode 100644 index 0000000000..a5149e60aa --- /dev/null +++ b/frontend/libs/components/model-selector/src/lib/provider-selector.tsx @@ -0,0 +1,92 @@ +import { useEffect, useMemo } from "react"; +import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover"; +import { ModelPage } from "./model-pages"; +import { Model } from "@storyteller/model-list"; +import { Provider } from "@storyteller/tauri-api"; +import { getProviderDisplayName, getProviderIcon } from "./provider-icons"; +import { + useClassyModelSelectorStore, + useSelectedProviderForModel, +} from "./classy-model-selector-store"; + +interface ProviderSelectorProps { + page: ModelPage; + model: Model | undefined; + providersByModel?: Partial>; + panelTitle?: string; + buttonClassName?: string; + panelClassName?: string; + triggerLabel?: string; +} + +const DEFAULT_PROVIDER_OPTIONS: Provider[] = [ + Provider.ArtCraft, + Provider.Fal, + Provider.Sora, +]; + +export function ProviderSelector({ + page, + model, + providersByModel, + ...popoverProps +}: ProviderSelectorProps) { + const { setSelectedProvider } = useClassyModelSelectorStore(); + + const modelId = model?.id; + const allowedProviders: Provider[] = useMemo(() => { + if (!modelId) return DEFAULT_PROVIDER_OPTIONS; + return providersByModel?.[modelId] ?? DEFAULT_PROVIDER_OPTIONS; + }, [providersByModel, modelId]); + + const selectedProvider = useSelectedProviderForModel(page, modelId); + + useEffect(() => { + if (!modelId) return; + if (!selectedProvider && allowedProviders.length > 0) { + setSelectedProvider(page, modelId, allowedProviders[0]); + } + }, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]); + + const items: Omit[] = useMemo( + () => + allowedProviders.map((p) => ({ + label: getProviderDisplayName(p), + icon: getProviderIcon(p), + model: undefined, + provider: p, + })), + [allowedProviders] + ); + + const list = useMemo( + () => + items.map((item) => ({ + ...item, + selected: (item as any).provider === selectedProvider, + })), + [items, selectedProvider] + ); + + const handleSelect = (item: PopoverItem) => { + if (!modelId) return; + const prov: Provider | undefined = (item as any).provider as + | Provider + | undefined; + if (!prov) return; + setSelectedProvider(page, modelId, prov); + }; + + return ( + + ); +} + +export default ProviderSelector; diff --git a/frontend/libs/components/popover/src/index.ts b/frontend/libs/components/popover/src/index.ts index cbc99b417b..a3a4ab29fb 100644 --- a/frontend/libs/components/popover/src/index.ts +++ b/frontend/libs/components/popover/src/index.ts @@ -1,2 +1 @@ - -export * from './lib/popover'; +export * from "./lib/popover"; diff --git a/frontend/libs/components/popover/src/lib/popover.tsx b/frontend/libs/components/popover/src/lib/popover.tsx index 7db340fdfe..53a8045365 100644 --- a/frontend/libs/components/popover/src/lib/popover.tsx +++ b/frontend/libs/components/popover/src/lib/popover.tsx @@ -106,77 +106,64 @@ export const PopoverMenu = ({ end: "right-0", }; - // Track hover state and refs - const [isHovering, setIsHovering] = useState(false); - const hoverTimeoutRef = useRef(null); + // Hover timers and refs + const openTimeoutRef = useRef(null); + const closeTimeoutRef = useRef(null); const popoverButtonRef = useRef(null); - // Cleanup timeout on unmount + // Cleanup timeouts on unmount useEffect(() => { return () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } + if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current); + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); }; }, []); const handleButtonMouseEnter = (open: boolean, openFn: () => void) => { if (!(mode === "hoverSelect" || closeOnUnhover)) return; - - setIsHovering(true); - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; } - if (!open && mode === "hoverSelect") { - hoverTimeoutRef.current = setTimeout(() => { + if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = setTimeout(() => { openFn(); - }, 100); + }, 120); } }; const handleButtonMouseLeave = (closeFn: () => void) => { if (!(mode === "hoverSelect" || closeOnUnhover)) return; - - setIsHovering(false); - - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; } - - hoverTimeoutRef.current = setTimeout( + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = setTimeout( () => { - if (!isHovering) { - closeFn(); - } + closeFn(); }, - mode === "hoverSelect" ? 300 : 150 + mode === "hoverSelect" ? 200 : 120 ); }; const handlePanelMouseEnter = () => { if (!(mode === "hoverSelect" || closeOnUnhover)) return; - setIsHovering(true); - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; } }; const handlePanelMouseLeave = (closeFn: () => void) => { if (!(mode === "hoverSelect" || closeOnUnhover)) return; - setIsHovering(false); - - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - - hoverTimeoutRef.current = setTimeout( + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = setTimeout( () => { closeFn(); }, - mode === "hoverSelect" ? 300 : 150 + mode === "hoverSelect" ? 200 : 120 ); }; @@ -297,8 +284,8 @@ export const PopoverMenu = ({ className={twMerge( "group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all", item.selected - ? "bg-black/40 border-l-4 border-primary" - : "hover:bg-black/20", + ? "bg-ui-controls/70 border-l-4 border-primary" + : "hover:bg-ui-controls/50", item.disabled ? "!cursor-not-allowed opacity-50" : "" diff --git a/frontend/libs/model-list/src/lib/classes/Model.ts b/frontend/libs/model-list/src/lib/classes/Model.ts index 6f822d90c3..6bfe3d394f 100644 --- a/frontend/libs/model-list/src/lib/classes/Model.ts +++ b/frontend/libs/model-list/src/lib/classes/Model.ts @@ -14,7 +14,7 @@ export class Model { // A unique frontend-only string for the model readonly id: string; - // A unique identifier that Tauri uses for the model (this + // A unique identifier that Tauri uses for the model (this // might differ from our backend or other systems) readonly tauriId: string; @@ -40,6 +40,9 @@ export class Model { // A list of filterable "capabilities" that can be used to filter models. readonly tags: ModelTag[]; + // Optional provider preferences; two-to-many via page and providers. + readonly preferredProvidersByPage?: Partial>; + protected constructor(args: { id: string; tauriId: string; @@ -50,6 +53,7 @@ export class Model { selectorDescription: string; selectorBadges: string[]; tags?: ModelTag[]; + preferredProvidersByPage?: Partial>; }) { this.id = args.id; this.tauriId = args.tauriId; @@ -60,9 +64,10 @@ export class Model { this.selectorDescription = args.selectorDescription; this.selectorBadges = args.selectorBadges; this.tags = args.tags ?? []; + this.preferredProvidersByPage = args.preferredProvidersByPage; } - toLegacyBadges() : { label: string }[] { + toLegacyBadges(): { label: string }[] { return this.selectorBadges.map((b) => ({ label: b })); } diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIcons.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIcons.ts index 5fdc0dd572..f452695cf3 100644 --- a/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIcons.ts +++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIcons.ts @@ -29,7 +29,7 @@ export const getCreatorIconPath = (creator: ModelCreator): string | undefined => export const getCreatorIcon = ( creator: ModelCreator, - className = "h-4 w-4 invert" + className = "h-4 w-4 icon-auto-contrast" ): ReactNode | null => { const path = getCreatorIconPath(creator) ?? DEFAULT_ICON_PATH; return React.createElement("img", {