mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 20:11:49 +00:00
cost estimates for splats in frontend ui
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user