cost estimates for splats in frontend ui

This commit is contained in:
bflatastic committed 2026-03-10 02:19:59 +07:00
1 parent 56d1649a9f
commit f7e7c132bc
6 files changed
+176 -15

No files matched your search

@@ -37,6 +37,11 @@ import {
useSelectedModel,
useSelectedProviderForModel,
} from "@storyteller/ui-model-selector";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -92,7 +97,13 @@ export const ImageTo3DExperience = ({
EnqueueImageTo3dObjectModel.Hunyuan3d3,
);
const selectedWorldModel = useSelectedModel(WORLD_MODEL_PAGE);
const selectedWorldProvider = useSelectedProviderForModel(WORLD_MODEL_PAGE, selectedWorldModel?.id);
const selectedWorldProvider = useSelectedProviderForModel(
WORLD_MODEL_PAGE,
selectedWorldModel?.id,
);
const worldCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[WORLD_MODEL_PAGE],
);
const [uploadedPreview, setUploadedPreview] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const [prompt, setPrompt] = useState("");
@@ -709,7 +720,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={2}
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 outline-none ring-2 ring-transparent transition-all focus:ring-primary/80"
style={{ maxHeight: "5em" }}
value={worldPrompt}
placeholder="Describe your 3D world (optional)..."
@@ -1058,7 +1069,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex justify-center gap-2.5",
hasResults ? "mt-3" : "mt-6",
hasResults ? "mt-3" : "mt-3",
)}
>
{variant === "object" && (
@@ -1081,6 +1092,7 @@ export const ImageTo3DExperience = ({
disabled={!canGenerate}
onClick={handleGenerate}
loading={isGenerating}
credits={variant === "world" ? worldCredits : undefined}
>
{`Generate ${variant === "object" ? "Object" : "World"}`}
</GenerateButton>
@@ -1102,17 +1114,23 @@ export const ImageTo3DExperience = ({
</animated.div>
{variant === "world" && (
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST}
page={WORLD_MODEL_PAGE}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
/>
</div>
<>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST}
page={WORLD_MODEL_PAGE}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={WORLD_MODEL_PAGE} />
<HelpMenuButton />
</div>
</>
)}
</div>
@@ -37,4 +37,5 @@ export const TAB_TO_MODEL_PAGE: Record<string, ModelPage> = {
"2D": ModelPage.Canvas2D,
"3D": ModelPage.Stage3D,
EDIT: ModelPage.ImageEditor,
IMAGE_TO_3D_WORLD: ModelPage.ImageTo3DWorld,
};
@@ -17,6 +17,7 @@ import {
CANVAS_2D_PAGE_MODEL_LIST,
STAGE_3D_PAGE_MODEL_LIST,
IMAGE_EDITOR_PAGE_MODEL_LIST,
IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST,
} from "@storyteller/ui-model-selector";
import {
usePrompt2DStore,
@@ -29,6 +30,7 @@ import { Model } from "@storyteller/model-list";
import { useCurrency } from "./use-currency";
import { useVideoCostEstimate } from "./useVideoCostEstimate";
import { useImageCostEstimate } from "./useImageCostEstimate";
import { useSplatCostEstimate } from "./useSplatCostEstimate";
// Drag handle subcomponent that Modal looks for
const DragHandle = ({ children }: { children: React.ReactNode }) => (
@@ -69,6 +71,10 @@ const getModelsForPage = (page: ModelPage | null): Model[] => {
return IMAGE_EDITOR_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.ImageTo3DWorld:
return IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
default:
return [];
}
@@ -119,7 +125,13 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
selectedModel,
selectedProvider,
);
const isEstimateLoading = isVideoEstimateLoading || isImageEstimateLoading;
const { isLoading: isSplatEstimateLoading } = useSplatCostEstimate(
activePage,
selectedModel,
selectedProvider,
);
const isEstimateLoading =
isVideoEstimateLoading || isImageEstimateLoading || isSplatEstimateLoading;
// Get generation settings from the appropriate stores based on active page
const prompt2D = usePrompt2DStore();
@@ -161,6 +173,12 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
generationCount: 1, // Edit doesn't have generation count in store
label: "Images",
};
case ModelPage.ImageTo3DWorld:
return {
resolution: undefined,
generationCount: 1,
label: "Worlds",
};
default:
return {
resolution: "1k",
@@ -179,6 +197,7 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
ModelPage.Stage3D,
ModelPage.ImageEditor,
ModelPage.ImageToVideo,
ModelPage.ImageTo3DWorld,
]);
const isLiveEstimatePage = LIVE_ESTIMATE_PAGES.has(activePage);
@@ -228,6 +247,8 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
return "Stage 3D";
case ModelPage.ImageEditor:
return "Image Editor";
case ModelPage.ImageTo3DWorld:
return "Image to 3D World";
default:
return null;
}
@@ -252,6 +273,8 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
"kling_3p0_pro",
"seedance_1p5_pro",
"seedance_2p0",
"marble_0p1_mini",
"marble_0p1_plus",
]);
const hasCostData =
selectedModel != null && MODELS_WITH_COST_DATA.has(selectedModel.id);
@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { ModelPage } from "@storyteller/ui-model-selector";
import { Model } from "@storyteller/model-list";
import { GenerationProvider } from "@storyteller/api-enums";
import {
EstimateSplatCost,
isEstimateSplatCostSuccess,
type CommonSplatModel,
} from "@storyteller/tauri-api";
import { useCostBreakdownModalStore } from "./cost-breakdown-modal-store";
const SPLAT_PAGES = new Set<ModelPage>([ModelPage.ImageTo3DWorld]);
export function useSplatCostEstimate(
activePage: ModelPage,
selectedModel: Model | null | undefined,
selectedProvider: string | null | undefined,
): { isLoading: boolean } {
const [isLoading, setIsLoading] = useState(false);
const setEstimatedCreditsForPage = useCostBreakdownModalStore(
(s) => s.setEstimatedCreditsForPage,
);
useEffect(() => {
if (!SPLAT_PAGES.has(activePage) || !selectedModel) {
return;
}
const commonModel = selectedModel.tauriId as CommonSplatModel;
if (!commonModel) {
setEstimatedCreditsForPage(activePage, null);
return;
}
const provider =
(selectedProvider as GenerationProvider | null | undefined) ??
GenerationProvider.Artcraft;
setIsLoading(true);
EstimateSplatCost({
model: commonModel,
provider,
has_reference_image: true,
})
.then((result) => {
if (isEstimateSplatCostSuccess(result)) {
setEstimatedCreditsForPage(
activePage,
result.payload.cost_in_credits ?? null,
);
} else {
setEstimatedCreditsForPage(activePage, null);
}
})
.catch(() => {
setEstimatedCreditsForPage(activePage, null);
})
.finally(() => {
setIsLoading(false);
});
}, [activePage, selectedModel?.id, selectedProvider]);
return { isLoading };
}
+1
View File
@@ -42,4 +42,5 @@ export * from "./lib/system/GetAppInfo.js";
export * from "./lib/util/LoadWithoutCors.js";
export * from "./lib/worldlabs/WorldLabsGetCredentialInfo.js";
export * from "./lib/cost_estimate/EstimateImageCost.js";
export * from "./lib/cost_estimate/EstimateSplatCost.js";
export * from "./lib/cost_estimate/EstimateVideoCost.js";
@@ -0,0 +1,53 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult, CommandSuccessStatus } from "../common/CommandStatus";
import { GenerationProvider } from "@storyteller/api-enums";
// Matches Rust CommonSplatModel enum (snake_case serde)
export type CommonSplatModel = "marble_0p1_mini" | "marble_0p1_plus";
export interface EstimateSplatCostRequest {
model: CommonSplatModel;
provider: GenerationProvider;
has_reference_image?: boolean;
}
export interface EstimateSplatCostPayload {
success: boolean;
cost_in_credits?: number;
cost_in_usd_cents?: number;
is_free: boolean;
is_unlimited: boolean;
is_rate_limited: boolean;
has_watermark: boolean;
}
export interface EstimateSplatCostSuccess extends CommandResult {
payload: EstimateSplatCostPayload;
}
export interface EstimateSplatCostErrorPayload {
success: boolean;
error_type: "invalid_provider_for_model" | "invalid_input";
error_message: string;
}
export interface EstimateSplatCostErrorResult extends CommandResult {
error_details?: EstimateSplatCostErrorPayload;
}
export type EstimateSplatCostResult =
| EstimateSplatCostSuccess
| EstimateSplatCostErrorResult;
export const EstimateSplatCost = async (
request: EstimateSplatCostRequest,
): Promise<EstimateSplatCostResult> => {
const result = await invoke("estimate_splat_cost_command", { request });
return result as EstimateSplatCostResult;
};
export function isEstimateSplatCostSuccess(
r: EstimateSplatCostResult,
): r is EstimateSplatCostSuccess {
return r.status === CommandSuccessStatus.Success;
}