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