From dd286c0a237aa5a4b9cf41ad8937ce011e33ccaf Mon Sep 17 00:00:00 2001 From: spoce Date: Wed, 1 Jul 2026 13:38:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MultiAngleEditor.tsx | 112 +++++++-------- .../composer/GenerationComposer.tsx | 131 ++++++++++-------- .../composer/NodeMultiAnglePanel.tsx | 36 ++++- src/components/nodes/ImageInputNode.tsx | 22 ++- src/components/nodes/SmartImageNode.tsx | 3 +- src/types/nodes.ts | 5 + src/utils/__tests__/composerPricing.test.ts | 75 +++++++--- .../__tests__/composerPricingContext.test.ts | 94 +++++++++++++ src/utils/composerPricing.ts | 73 ++++++---- src/utils/composerPricingContext.ts | 65 +++++++++ src/utils/nodeHandles.ts | 1 + src/utils/smartImageMode.ts | 2 + 12 files changed, 443 insertions(+), 176 deletions(-) create mode 100644 src/utils/__tests__/composerPricingContext.test.ts create mode 100644 src/utils/composerPricingContext.ts diff --git a/src/components/MultiAngleEditor.tsx b/src/components/MultiAngleEditor.tsx index a831e2d8..0aa0cdc8 100644 --- a/src/components/MultiAngleEditor.tsx +++ b/src/components/MultiAngleEditor.tsx @@ -6,6 +6,7 @@ import { type PointerEvent, type ReactNode, } from "react"; +import { DownOutlined, LeftOutlined, RightOutlined, UpOutlined } from "@ant-design/icons"; import { useI18n, type TranslationKey } from "@/i18n"; export type MultiAnglePresetId = "custom" | "fisheye" | "tilt" | "frontOverhead" | "frontLow" | "panoramicOverhead" | "back"; @@ -177,15 +178,11 @@ export function MultiAngleEditor({ actions?: ReactNode; }) { const { t } = useI18n(); - const horizontalRadians = (settings.horizontal * Math.PI) / 180; - const cameraRadius = 90 - (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 24; - const cameraX = 100 + Math.sin(horizontalRadians) * cameraRadius; - const cameraY = 100 - (settings.vertical / 60) * 52; - const globePitchRotation = -(settings.vertical / 60) * 16; - const globePitchScaleY = 1 - Math.abs(settings.vertical) / 260; - const sightTargetY = 100; - const previewScale = 0.72 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.72; - const previewRotateY = Math.sin(horizontalRadians) * 28; + const previewScale = 0.58 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.64; + const cubeRotateX = -settings.vertical; + const cubeRotateY = settings.horizontal; + const cubeFaceClass = + "absolute inset-0 flex items-center justify-center rounded-xl border border-[var(--border-strong)]/55 bg-[var(--surface-2)]/35 text-xs font-medium text-[var(--text-muted)] [backface-visibility:visible]"; const updateHorizontal = (value: number) => { onChange({ @@ -296,7 +293,7 @@ export function MultiAngleEditor({
- - - - - - - - - - - - - - - 0 - 90 - 180 - 270 - - - -
- {previewImage ? ( - - ) : ( -
- {t("multiAngle.noImage")} +
+
+
+ {previewImage ? ( + + ) : ( +
+ {t("multiAngle.noImage")} +
+ )} +
+
+ 右 +
+
+ 左
- )} +
+ 上 +
+
+ 下 +
+
+ 背 +
+
- - - - - - - - -
diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index e26bd754..0639092c 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -25,7 +25,6 @@ import { import { buildInitialGenerationNodeData, normalizeParametersBySchema, - readExtensionFieldDefaultExtraTaskParams, readAudioGenerationConfig, readImageGenerationConfig, readVideoGenerationConfig, @@ -43,12 +42,14 @@ import { type ComposerReferenceMaterialType, } from "@/utils/composerReferenceSubjects"; import { - buildComposerPopiserverTaskPricePayload, buildPopiserverPriceQuoteRequest, - buildPopiserverPointsEstimatePayload, getQuotedPointAmount, type PopiserverPriceQuoteResponse, } from "@/utils/composerPricing"; +import { + buildComposerPricingContext, + buildFinalComposerPrompt, +} from "@/utils/composerPricingContext"; import { selectedModelCapabilities } from "@/utils/selectedModel"; import { ComposerPromptMaterials, @@ -74,6 +75,7 @@ import type { WorkflowNodeData, } from "@/types"; import type { ModelParameter } from "@/lib/providers/types"; +import type { ConnectedInputs } from "@/store/utils/connectedInputs"; type ComposerConfig = GenerationNodeConfig; type ComposerDraft = GenerationNodeConfig; @@ -83,7 +85,7 @@ type PricingInputsSnapshot = { prompt: string; activeModelDetail: PopiModelDetail | undefined; modelSchema: ModelParameter[]; - nodeExtraTaskParams: Record; + connectedInputs: ConnectedInputs | null; connectedVideoDurationSeconds: number; popiserverPricingMedia: PopiserverTaskPayloadMedia; isHidden: boolean; @@ -122,14 +124,6 @@ function finiteNumberFromUnknown(value: unknown): number | null { return null; } -function buildReferenceOnlyPrompt(): string { - return "Generate from the input image while keeping the subject, style, and composition consistent."; -} - -function joinPromptParts(parts: Array): string { - return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part)).join(","); -} - function getConnectedTextItems(inputs: { text: string | null; textItems: string[] } | null | undefined): string[] { if (!inputs) return []; const items = inputs.textItems.length > 0 ? inputs.textItems : inputs.text ? [inputs.text] : []; @@ -387,10 +381,43 @@ function uniqueReferenceVideoInputs(videos: ComposerReferenceInput[]): ComposerR } function readWorkflowNodeParameters(node: WorkflowNode | null | undefined): Record | null { - const parameters = (node?.data as { parameters?: unknown } | undefined)?.parameters; - return parameters && typeof parameters === "object" && !Array.isArray(parameters) + const data = node?.data as Record | undefined; + const parameters = data?.parameters; + const baseParameters = parameters && typeof parameters === "object" && !Array.isArray(parameters) ? parameters as Record - : null; + : {}; + if (!data) return Object.keys(baseParameters).length > 0 ? baseParameters : null; + + const legacyParameters: Record = {}; + if (!("ratio" in baseParameters) && !("aspectRatio" in baseParameters) && !("aspect_ratio" in baseParameters)) { + const aspectRatio = data.aspectRatio; + const normalizedAspectRatio = typeof aspectRatio === "string" && aspectRatio.length > 0 ? aspectRatio : "16:9"; + legacyParameters.aspect_ratio = normalizedAspectRatio; + legacyParameters.ratio = normalizedAspectRatio; + } + if (!("resolution" in baseParameters)) { + const resolution = data.resolution; + if (typeof resolution === "string" && resolution.length > 0) legacyParameters.resolution = resolution; + } + if (!("width" in baseParameters) && typeof data.width === "number") legacyParameters.width = data.width; + if (!("height" in baseParameters) && typeof data.height === "number") legacyParameters.height = data.height; + if (node?.type === "nanoBanana") { + if (!("width" in baseParameters) && !("width" in legacyParameters)) legacyParameters.width = 1280; + if (!("height" in baseParameters) && !("height" in legacyParameters)) legacyParameters.height = 720; + } + if (!("duration" in baseParameters) && !("durationSeconds" in baseParameters) && !("duration_seconds" in baseParameters)) { + const durationSeconds = finiteNumberFromUnknown(data.durationSeconds); + if (durationSeconds !== null && durationSeconds > 0) { + legacyParameters.duration = durationSeconds; + legacyParameters.output_video_duration_seconds = durationSeconds; + } + } + + const nextParameters = { + ...legacyParameters, + ...baseParameters, + }; + return Object.keys(nextParameters).length > 0 ? nextParameters : null; } function readComposerAspectRatio(config: ComposerConfig): string | undefined { @@ -468,7 +495,16 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = const { screenToFlowPosition } = useReactFlow(); const context = useMemo( - () => deriveComposerContextForNodeId(nodes, edges, targetNodeId), + () => { + const targetExists = nodes.some((node) => node.id === targetNodeId); + return targetExists + ? deriveComposerContextForNodeId(nodes, edges, targetNodeId) + : deriveComposerContextForNodeId( + nodes, + edges, + nodes.find((node) => node.selected)?.id ?? targetNodeId + ); + }, [edges, nodes, targetNodeId] ); const contextKey = contextIdentity(context); @@ -529,11 +565,7 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = (runContext.mode === "node-edit" || runContext.mode === "process-node") && submitNode ? useWorkflowStore.getState().getConnectedInputs(submitNode.id) : null; - const connectedTextItems = getConnectedTextItems(connectedInputs); - const promptForSubmit = prompt.trim(); - const fallbackPrompt = buildReferenceOnlyPrompt(); - const localPrompt = promptForSubmit || (connectedTextItems.length > 0 ? "" : fallbackPrompt); - const finalPrompt = joinPromptParts([...connectedTextItems, localPrompt]); + const finalPrompt = buildFinalComposerPrompt(prompt, connectedInputs); const submitNodeType = runContext.mode === "empty-create" ? inferNodeTypeFromModel(config.selectedModel) : runContext.nodeType; if (config.selectedModel.modelId) await fetchModelDetail(config.selectedModel.modelId); @@ -645,6 +677,10 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden = }); }, [activeComposerNodeId, cancelNodeTask, t]); + if (context.mode === "multi-select" || (context.mode === "unsupported-node" && !context.node)) { + return null; + } + return ( { const liveConfig = buildConfigWithLiveNodeParameters(baseConfig); - const detail = liveConfig.selectedModel.modelId - ? useModelStore.getState().detailsById[liveConfig.selectedModel.modelId] - : undefined; - const detailSchema = detail?.parameters ?? []; - const extraTaskParams = { - ...readExtensionFieldDefaultExtraTaskParams(detail?.extensionFields), - ...baseConfig.extraTaskParams, - ...nodeExtraTaskParams, - }; return { ...liveConfig, parameters: { ...(baseConfig.parameters ?? {}), ...(liveConfig.parameters ?? {}), }, - extraTaskParams, }; - }, [buildConfigWithLiveNodeParameters, nodeExtraTaskParams]); + }, [buildConfigWithLiveNodeParameters]); const persistCurrentComposerConfig = useCallback(() => { const liveConfig = buildConfigWithLiveNodeParameters(config); @@ -892,7 +918,7 @@ function ComposerEditor({ prompt, activeModelDetail, modelSchema, - nodeExtraTaskParams, + connectedInputs, connectedVideoDurationSeconds, popiserverPricingMedia, isHidden, @@ -931,26 +957,17 @@ function ComposerEditor({ ...currentParameters, ...normalizeParametersBySchema(currentParameters, detailSchema), }, - extraTaskParams: { - ...readExtensionFieldDefaultExtraTaskParams(detail?.extensionFields), - ...liveConfig.extraTaskParams, - ...inputs.nodeExtraTaskParams, - }, + extraTaskParams: liveConfig.extraTaskParams ?? {}, }; - const pricedConfig = { ...normalizedConfig, prompt: nextPrompt }; - const pointsPayload = buildPopiserverPointsEstimatePayload( - pricedConfig, - context.capability, - inputs.connectedVideoDurationSeconds, - inputs.popiserverPricingMedia - ); - const taskPricePayload = buildComposerPopiserverTaskPricePayload( - pricedConfig, - context.capability, - inputs.popiserverPricingMedia - ); - - const quoteRequest = buildPopiserverPriceQuoteRequest(pointsPayload, taskPricePayload); + const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext({ + config: normalizedConfig, + prompt: nextPrompt, + capability: context.capability, + connectedInputs: inputs.connectedInputs, + modelDetail: detail, + media: inputs.popiserverPricingMedia, + inputVideoDurationSeconds: inputs.connectedVideoDurationSeconds, + })); if (!quoteRequest) { setPriceState({ amount: null, isLoading: false }); return; @@ -988,13 +1005,10 @@ function ComposerEditor({ updateNodeExtraTaskParams(context.node.id, values); const nextConfig = { ...config, - extraTaskParams: { - ...readExtensionFieldDefaultExtraTaskParams(activeModelDetail?.extensionFields), - ...values, - }, + extraTaskParams: values, }; setConfig(nextConfig); - }, [activeModelDetail, config, context.node, updateNodeExtraTaskParams]); + }, [config, context.node, updateNodeExtraTaskParams]); const schedulePriceRefresh = useCallback(() => { if (priceRefreshTimerRef.current !== null) { @@ -1028,6 +1042,7 @@ function ComposerEditor({ }, [ clearScheduledPriceRefresh, config.audioVoiceId, + config.extraTaskParams, config.imageCount, config.parameters, config.referenceSubjectList, diff --git a/src/components/composer/NodeMultiAnglePanel.tsx b/src/components/composer/NodeMultiAnglePanel.tsx index 517ad6e2..31a529a7 100644 --- a/src/components/composer/NodeMultiAnglePanel.tsx +++ b/src/components/composer/NodeMultiAnglePanel.tsx @@ -50,6 +50,7 @@ function buildMultiAngleNodeData( export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAnglePanelProps) { const { t } = useI18n(); const addNode = useWorkflowStore((state) => state.addNode); + const getNodeById = useWorkflowStore((state) => state.getNodeById); const onConnect = useWorkflowStore((state) => state.onConnect); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); @@ -115,11 +116,43 @@ export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAng selectSingleNode(newNodeId); onClose(); await regenerateNode(newNodeId); + const generatedNode = getNodeById(newNodeId); + const generatedData = generatedNode?.data as NanoBananaNodeData | undefined; + if (generatedData?.outputImage) { + const resultNodeId = addNode( + "smartImage", + { + x: node.position.x + currentWidth + 560, + y: node.position.y, + }, + { + image: generatedData.outputImage, + imageRef: undefined, + previewImage: generatedData.previewImg, + filename: `multi-angle-${Date.now()}.png`, + dimensions: null, + label: t("imageInput.multiAngle"), + derivedImage: { + sourceNodeId: newNodeId, + operation: "multiAngle", + }, + disablePreviewModal: true, + } + ); + onConnect({ + source: newNodeId, + sourceHandle: SINGLE_OUTPUT_HANDLE_ID, + target: resultNodeId, + targetHandle: SINGLE_INPUT_HANDLE_ID, + }); + selectSingleNode(resultNodeId); + } } finally { setIsGenerating(false); } }, [ addNode, + getNodeById, imageSource, isGenerating, node, @@ -129,13 +162,14 @@ export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAng selectSingleNode, selectedModel, settings, + t, ]); return (
event.stopPropagation()} >
diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 62232fa3..655089d8 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -130,6 +130,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal const preferredDisplayImage = nodeData.previewImage || adaptiveImage || nodeData.image; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const addNode = useWorkflowStore((state) => state.addNode); + const onConnect = useWorkflowStore((state) => state.onConnect); const getNodeById = useWorkflowStore((state) => state.getNodeById); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const openInlinePanel = useNodeInlinePanelStore((state) => state.openPanel); @@ -353,7 +354,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal : typeof currentNode?.style?.width === "number" ? currentNode.style.width : IMAGE_INPUT_DIMENSIONS.width; - const nextNodeId = addNode("imageInput", { + const nextNodeId = addNode("smartImage", { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, { @@ -363,6 +364,17 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal filename, dimensions: { width: payload.outputWidth, height: payload.outputHeight }, label: t("imageInput.crop"), + derivedImage: { + sourceNodeId: id, + operation: "crop", + }, + disablePreviewModal: true, + }); + onConnect({ + source: id, + sourceHandle: SINGLE_OUTPUT_HANDLE_ID, + target: nextNodeId, + targetHandle: SINGLE_INPUT_HANDLE_ID, }); selectSingleNode(nextNodeId); setIsCropping(false); @@ -371,7 +383,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal } finally { setIsCropUploading(false); } - }, [addNode, displayImage, getNodeById, id, nodeData.image, selectSingleNode, t]); + }, [addNode, displayImage, getNodeById, id, nodeData.image, onConnect, selectSingleNode, t]); const handleSelectAsset = useCallback((asset: PopiAssetItem) => { const preview = getPopiImageAssetPreview(asset); @@ -439,7 +451,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal const selectedNodeCount = useSelectedNodeCount(); const showSelectedActions = Boolean(selected && selectedNodeCount === 1); const selectedActions: NodeActionCapsuleAction[] = hasImage - ? [ + ? ([ { key: "crop", icon: , @@ -504,7 +516,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal handleRemove(); }, }, - ] + ] satisfies NodeActionCapsuleAction[]).filter((action) => !nodeData.disablePreviewModal || action.key !== "preview") : [ { key: "upload", @@ -690,7 +702,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal style={{ opacity: 0, zIndex: 10 }} /> - {isPreviewOpen && (nodeData.image || displayImage) && ( + {!nodeData.disablePreviewModal && isPreviewOpen && (nodeData.image || displayImage) && ( state.providerEnvStatus); const updateNodeInternals = useUpdateNodeInternals(); const mode = deriveSmartImageMode(id, data, edges); + const isDerivedImage = Boolean(data.derivedImage); useEffect(() => { updateNodeInternals(id); @@ -329,5 +330,5 @@ export function SmartImageNode({ id, data, selected }: NodeProps; } - return ; + return ; } diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 2faaaada..90e9e6f9 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -79,6 +79,11 @@ export interface ImageInputNodeData extends BaseNodeData { filename: string | null; dimensions: { width: number; height: number } | null; isOptional?: boolean; + derivedImage?: { + sourceNodeId: string; + operation: "crop" | "multiAngle"; + }; + disablePreviewModal?: boolean; } /** diff --git a/src/utils/__tests__/composerPricing.test.ts b/src/utils/__tests__/composerPricing.test.ts index 58a5cf23..f2c923d3 100644 --- a/src/utils/__tests__/composerPricing.test.ts +++ b/src/utils/__tests__/composerPricing.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { - buildComposerPopiserverTaskPricePayload, buildPopiserverPriceQuoteRequest, buildPopiserverPointsEstimatePayload, getQuotedPointAmount, } from "@/utils/composerPricing"; import type { SelectedModel } from "@/types"; import type { GenerationNodeConfig } from "@/utils/generationConfig"; +import type { ComposerPricingContext } from "@/utils/composerPricingContext"; function model(overrides: Partial = {}): SelectedModel { return { @@ -31,9 +31,20 @@ function draft(overrides: Partial = {}): GenerationNodeCon }; } +function context(overrides: Partial & { config?: GenerationNodeConfig } = {}): ComposerPricingContext { + return { + config: overrides.config ?? draft(), + capability: overrides.capability ?? "image", + media: overrides.media ?? {}, + inputVideoDurationSeconds: overrides.inputVideoDurationSeconds ?? 0, + }; +} + describe("composerPricing", () => { it("builds PopiServer estimate payloads with provider model aliases", () => { - const payload = buildPopiserverPointsEstimatePayload(draft({ + const payload = buildPopiserverPointsEstimatePayload(context({ + inputVideoDurationSeconds: 12, + config: draft({ selectedModel: model({ modelId: "123", displayName: "Token billed video", @@ -42,54 +53,72 @@ describe("composerPricing", () => { aiModelCodeAlias: "video-alias", }), parameters: { duration: 8 }, - }), "video", 12); + }), + })); expect(payload).toEqual({ model_name: "video-alias", estimation_type: "auto", - parameters: { duration: 8 }, + parameters: { + duration: 8, + inputPrompt: "prompt", + input_video_duration_seconds: 12, + }, imageCount: 1, input_video_duration_seconds: 12, }); }); - it("skips task price payloads for llm and Seedance estimates", () => { - expect(buildComposerPopiserverTaskPricePayload(draft(), "llm")).toBeNull(); - expect(buildComposerPopiserverTaskPricePayload(draft({ + it("skips task price quote requests for llm and Seedance estimates", () => { + expect(buildPopiserverPriceQuoteRequest(context({ + capability: "llm", + config: draft(), + }))).toBeNull(); + expect(buildPopiserverPriceQuoteRequest(context({ + capability: "video", + config: draft({ selectedModel: model({ displayName: "Seedance" }), - }), "video")).toBeNull(); + }), + }))).toMatchObject({ source: "estimate" }); }); it("uses PopiServer estimate only for billingMethod 2 or Seedance models", () => { - expect(buildPopiserverPointsEstimatePayload(draft({ + expect(buildPopiserverPointsEstimatePayload(context({ config: draft({ selectedModel: model({ billingMethod: 2 }), - }), "image", 0)).not.toBeNull(); - expect(buildPopiserverPointsEstimatePayload(draft({ + }) }))).not.toBeNull(); + expect(buildPopiserverPointsEstimatePayload(context({ config: draft({ selectedModel: model({ billingMethod: 1 }), - }), "image", 0)).toBeNull(); - expect(buildPopiserverPointsEstimatePayload(draft({ + }) }))).toBeNull(); + expect(buildPopiserverPointsEstimatePayload(context({ + capability: "video", + config: draft({ selectedModel: model({ - displayName: "Seedance Pro", - billingMethod: 1, + displayName: "Seedance Pro", + billingMethod: 1, + }), }), - }), "video", 0)).not.toBeNull(); + }))).not.toBeNull(); }); it("builds unified PopiServer quote requests", () => { - const pointsPayload = buildPopiserverPointsEstimatePayload(draft({ + const estimateContext = context({ config: draft({ selectedModel: model({ billingMethod: 2 }), - }), "image", 0); - const taskPricePayload = { aiModelId: 32 }; + }) }); + const pointsPayload = buildPopiserverPointsEstimatePayload(estimateContext); - expect(buildPopiserverPriceQuoteRequest(pointsPayload, taskPricePayload)).toEqual({ + expect(buildPopiserverPriceQuoteRequest(estimateContext)).toEqual({ source: "estimate", payload: pointsPayload, }); - expect(buildPopiserverPriceQuoteRequest(null, taskPricePayload)).toEqual({ + expect(buildPopiserverPriceQuoteRequest(context({ config: draft({ + selectedModel: model({ + billingMethod: 1, + aiModelId: 32, + }), + }) }))).toMatchObject({ source: "task-price", - payload: taskPricePayload, }); - expect(buildPopiserverPriceQuoteRequest(null, null)).toBeNull(); + expect(buildPopiserverPriceQuoteRequest(context())).toBeNull(); expect(getQuotedPointAmount({ data: { points: "36" } })).toBe(36); expect(getQuotedPointAmount({ data: { points: "bad" } })).toBeNull(); }); diff --git a/src/utils/__tests__/composerPricingContext.test.ts b/src/utils/__tests__/composerPricingContext.test.ts new file mode 100644 index 00000000..00254833 --- /dev/null +++ b/src/utils/__tests__/composerPricingContext.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + buildComposerPricingContext, + buildFinalComposerPrompt, +} from "@/utils/composerPricingContext"; +import type { ConnectedInputs } from "@/store/utils/connectedInputs"; +import type { GenerationNodeConfig } from "@/utils/generationConfig"; + +const emptyConnectedInputs: ConnectedInputs = { + images: [], + videos: [], + videoItems: [], + audio: [], + model3d: null, + text: null, + textItems: [], + batchTextItems: [], + dynamicInputs: {}, + easeCurve: null, +}; + +function config(overrides: Partial = {}): GenerationNodeConfig { + return { + prompt: "local prompt", + selectedModel: { + provider: "popiserver", + modelId: "model-1", + displayName: "Model 1", + capabilities: ["text-to-image"], + }, + inputImages: [], + imageCount: 1, + referenceSubjectList: [], + parameters: {}, + extraTaskParams: {}, + ...overrides, + }; +} + +describe("composerPricingContext", () => { + it("builds the final prompt from connected text and local composer text", () => { + expect(buildFinalComposerPrompt("local", { + ...emptyConnectedInputs, + textItems: ["first", "second"], + })).toBe("first,second,local"); + }); + + it("uses local extraTaskParams with model defaults for pricing", () => { + const context = buildComposerPricingContext({ + config: config({ + extraTaskParams: { + generate_audio: false, + }, + }), + prompt: "local", + capability: "image", + connectedInputs: { + ...emptyConnectedInputs, + text: "connected", + textItems: ["connected"], + }, + modelDetail: { + id: 1, + name: "Model 1", + code: "model-1", + kind: "image", + inputs: [], + parameters: [], + extensionFields: [ + { + name: "generate_audio", + type: "boolean", + default: true, + options: [], + }, + { + name: "quality", + type: "string", + default: "high", + options: [], + }, + ], + }, + media: {}, + inputVideoDurationSeconds: 0, + }); + + expect(context.config.prompt).toBe("connected,local"); + expect(context.config.extraTaskParams).toEqual({ + generate_audio: false, + quality: "high", + }); + }); +}); diff --git a/src/utils/composerPricing.ts b/src/utils/composerPricing.ts index 5f7a0532..f330b9d7 100644 --- a/src/utils/composerPricing.ts +++ b/src/utils/composerPricing.ts @@ -1,13 +1,11 @@ import { buildPopiserverTaskPricePayload, - type GenerationCapability, - type GenerationNodeConfig, - type PopiserverTaskPayloadMedia, type PopiserverTaskPricePayload, } from "@/utils/generationConfig"; import { buildPopiserverLlmParameterPayload } from "@/utils/popiserverLlmPayload"; import type { LLMModelType, SelectedModel } from "@/types"; import { selectedModelMetadataValue } from "@/utils/selectedModel"; +import type { ComposerPricingContext } from "@/utils/composerPricingContext"; export type PopiserverPriceQuoteSource = "estimate" | "task-price"; @@ -37,8 +35,6 @@ export type PopiserverPointsEstimatePayload = { estimation_type: "auto"; }; -type ComposerPricingCapability = GenerationCapability | "llm"; - function finiteNumberFromUnknown(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { @@ -76,6 +72,10 @@ function shouldUsePopiserverTaskPrice(model: SelectedModel): boolean { return getPopiserverBillingMethod(model) === 1; } +function shouldUsePopiserverSeedanceEstimate(model: SelectedModel): boolean { + return model.provider === "popiserver" && isSeedanceVideoEstimateModel(model); +} + function getPopiserverModelName(model: SelectedModel): string { return ( stringFromUnknown(selectedModelMetadataValue(model, "aiModelCodeAlias")) || @@ -85,35 +85,55 @@ function getPopiserverModelName(model: SelectedModel): string { } export function buildPopiserverPointsEstimatePayload( - draft: GenerationNodeConfig, - capability: ComposerPricingCapability, - inputVideoDurationSeconds: number, - media: PopiserverTaskPayloadMedia = {} + context: ComposerPricingContext ): PopiserverPointsEstimatePayload | null { - if (!shouldUsePopiserverPointsEstimate(draft.selectedModel) || !draft.selectedModel.modelId) { + const { config, capability, inputVideoDurationSeconds, media } = context; + const { selectedModel } = config; + if (!shouldUsePopiserverPointsEstimate(selectedModel) || !selectedModel.modelId) { return null; } if (capability === "llm") { return { parameters: buildPopiserverLlmParameterPayload({ - prompt: draft.prompt, - model: draft.selectedModel.modelId as LLMModelType, - aiModelId: finiteNumberFromUnknown(selectedModelMetadataValue(draft.selectedModel, "aiModelId")) ?? undefined, + prompt: config.prompt, + model: selectedModel.modelId as LLMModelType, + aiModelId: finiteNumberFromUnknown(selectedModelMetadataValue(selectedModel, "aiModelId")) ?? undefined, images: media.images, videos: media.videos, + parameters: config.parameters, }), - model_name: getPopiserverModelName(draft.selectedModel), + model_name: getPopiserverModelName(selectedModel), estimation_type: "auto", }; } + if (shouldUsePopiserverSeedanceEstimate(selectedModel)) { + return { + model_name: getPopiserverModelName(selectedModel), + estimation_type: "auto", + parameters: { + ...(config.parameters ?? {}), + input_video_duration_seconds: inputVideoDurationSeconds, + }, + }; + } + return { - model_name: getPopiserverModelName(draft.selectedModel), + model_name: getPopiserverModelName(selectedModel), estimation_type: "auto", - parameters: draft.parameters ?? {}, - ...(draft.imageCount ? { imageCount: draft.imageCount } : {}), - ...(draft.audioVoiceId ? { audioVoiceId: draft.audioVoiceId } : {}), + parameters: { + ...(config.parameters ?? {}), + ...(Object.keys(config.extraTaskParams ?? {}).length > 0 ? { extraTaskParams: config.extraTaskParams } : {}), + ...(config.referenceSubjectList?.length ? { referenceSubjectList: config.referenceSubjectList } : {}), + ...(config.prompt.trim() ? { inputPrompt: config.prompt.trim() } : {}), + ...(media.images?.length ? { images: media.images } : {}), + ...(media.videos?.length ? { videos: media.videos } : {}), + ...(media.voices?.length ? { voices: media.voices } : {}), + ...(inputVideoDurationSeconds > 0 ? { input_video_duration_seconds: inputVideoDurationSeconds } : {}), + }, + ...(config.imageCount ? { imageCount: config.imageCount } : {}), + ...(config.audioVoiceId ? { audioVoiceId: config.audioVoiceId } : {}), ...(inputVideoDurationSeconds > 0 ? { input_video_duration_seconds: inputVideoDurationSeconds } : {}), }; } @@ -123,29 +143,30 @@ export function getQuotedPointAmount(response: PopiserverPriceQuoteResponse): nu } export function buildComposerPopiserverTaskPricePayload( - draft: GenerationNodeConfig, - capability: ComposerPricingCapability, - media: PopiserverTaskPayloadMedia = {} + context: ComposerPricingContext ) { - if (!shouldUsePopiserverTaskPrice(draft.selectedModel)) return null; - if (shouldUsePopiserverPointsEstimate(draft.selectedModel)) return null; + const { config, capability, media } = context; + if (!shouldUsePopiserverTaskPrice(config.selectedModel)) return null; + if (shouldUsePopiserverPointsEstimate(config.selectedModel)) return null; return buildPopiserverTaskPricePayload( - draft, + config, capability === "llm" ? "all" : capability, media ); } export function buildPopiserverPriceQuoteRequest( - pointsPayload: PopiserverPointsEstimatePayload | null, - taskPricePayload: PopiserverTaskPricePayload | null + context: ComposerPricingContext ): PopiserverPriceQuoteRequest | null { + const pointsPayload = buildPopiserverPointsEstimatePayload(context); if (pointsPayload) { return { source: "estimate", payload: pointsPayload, }; } + + const taskPricePayload = buildComposerPopiserverTaskPricePayload(context); if (taskPricePayload) { return { source: "task-price", diff --git a/src/utils/composerPricingContext.ts b/src/utils/composerPricingContext.ts new file mode 100644 index 00000000..5f0c8c11 --- /dev/null +++ b/src/utils/composerPricingContext.ts @@ -0,0 +1,65 @@ +import type { ComposerCapability } from "@/utils/composerNodeDescriptor"; +import { + readExtensionFieldDefaultExtraTaskParams, + type GenerationNodeConfig, + type PopiserverTaskPayloadMedia, +} from "@/utils/generationConfig"; +import type { PopiModelDetail } from "@/store/modelStore"; +import type { ConnectedInputs } from "@/store/utils/connectedInputs"; + +export type ComposerPricingCapability = ComposerCapability; + +export type ComposerPricingContext = { + config: GenerationNodeConfig; + capability: ComposerPricingCapability; + media: PopiserverTaskPayloadMedia; + inputVideoDurationSeconds: number; +}; + +function getConnectedTextItems(inputs: ConnectedInputs | null | undefined): string[] { + if (!inputs) return []; + const items = inputs.textItems.length > 0 ? inputs.textItems : inputs.text ? [inputs.text] : []; + return items.map((item) => item.trim()).filter(Boolean); +} + +function buildReferenceOnlyPrompt(): string { + return "Generate from the input image while keeping the subject, style, and composition consistent."; +} + +function joinPromptParts(parts: Array): string { + return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part)).join(","); +} + +export function buildFinalComposerPrompt( + prompt: string, + connectedInputs: ConnectedInputs | null | undefined +): string { + const connectedTextItems = getConnectedTextItems(connectedInputs); + const promptForSubmit = prompt.trim(); + const localPrompt = promptForSubmit || (connectedTextItems.length > 0 ? "" : buildReferenceOnlyPrompt()); + return joinPromptParts([...connectedTextItems, localPrompt]); +} + +export function buildComposerPricingContext(input: { + config: GenerationNodeConfig; + prompt: string; + capability: ComposerPricingCapability; + connectedInputs: ConnectedInputs | null | undefined; + modelDetail?: PopiModelDetail | null; + media: PopiserverTaskPayloadMedia; + inputVideoDurationSeconds: number; +}): ComposerPricingContext { + return { + config: { + ...input.config, + prompt: buildFinalComposerPrompt(input.prompt, input.connectedInputs), + extraTaskParams: { + ...readExtensionFieldDefaultExtraTaskParams(input.modelDetail?.extensionFields), + ...(input.config.extraTaskParams ?? {}), + }, + }, + capability: input.capability, + media: input.media, + inputVideoDurationSeconds: input.inputVideoDurationSeconds, + }; +} diff --git a/src/utils/nodeHandles.ts b/src/utils/nodeHandles.ts index b55db52f..735e6270 100644 --- a/src/utils/nodeHandles.ts +++ b/src/utils/nodeHandles.ts @@ -219,6 +219,7 @@ export function normalizeConnectionHandles