Browse Source

清理数据

feature/071
spoce 2 weeks ago
parent
commit
dd286c0a23
  1. 112
      src/components/MultiAngleEditor.tsx
  2. 131
      src/components/composer/GenerationComposer.tsx
  3. 36
      src/components/composer/NodeMultiAnglePanel.tsx
  4. 22
      src/components/nodes/ImageInputNode.tsx
  5. 3
      src/components/nodes/SmartImageNode.tsx
  6. 5
      src/types/nodes.ts
  7. 75
      src/utils/__tests__/composerPricing.test.ts
  8. 94
      src/utils/__tests__/composerPricingContext.test.ts
  9. 73
      src/utils/composerPricing.ts
  10. 65
      src/utils/composerPricingContext.ts
  11. 1
      src/utils/nodeHandles.ts
  12. 2
      src/utils/smartImageMode.ts

112
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({
<div className={`grid ${compact ? "gap-3 md:grid-cols-[240px_1fr]" : "gap-4 md:grid-cols-[240px_1fr]"}`}>
<div
aria-label={t("multiAngle.sightControl")}
className={`relative touch-none overflow-hidden rounded-xl bg-[var(--surface-1)] cursor-grab active:cursor-grabbing ${compact ? "h-[210px]" : "h-60"}`}
className={`relative touch-none overflow-hidden rounded-xl bg-[var(--surface-1)] cursor-grab active:cursor-grabbing ${compact ? "h-[250px]" : "h-[280px]"}`}
data-composer-local-pointer="true"
data-horizontal={settings.horizontal}
data-vertical={settings.vertical}
@ -310,36 +307,15 @@ export function MultiAngleEditor({
onMouseMove={handleGlobeMouseMove}
onMouseUp={handleGlobeMouseUp}
>
<svg className="absolute inset-4 z-0 h-[calc(100%-2rem)] w-[calc(100%-2rem)] text-[var(--text-muted)] opacity-70" viewBox="0 0 200 200" fill="none">
<defs>
<radialGradient id="multi-angle-globe-glow" cx="50%" cy="44%" r="52%">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.2" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.02" />
</radialGradient>
</defs>
<circle cx="100" cy="100" r="78" fill="url(#multi-angle-globe-glow)" stroke="currentColor" strokeWidth="1" />
<g transform={`translate(100 100) rotate(${globePitchRotation}) scale(1 ${globePitchScaleY}) translate(-100 -100)`}>
<ellipse cx="100" cy="100" rx="78" ry="26" stroke="currentColor" strokeWidth="0.8" />
<ellipse cx="100" cy="100" rx="78" ry="52" stroke="currentColor" strokeWidth="0.8" />
<ellipse cx="100" cy="100" rx="26" ry="78" stroke="currentColor" strokeWidth="0.8" />
<ellipse cx="100" cy="100" rx="52" ry="78" stroke="currentColor" strokeWidth="0.8" />
<path d="M22 100h156M100 22v156" stroke="currentColor" strokeWidth="0.8" />
<text x="100" y="16" textAnchor="middle" fill="currentColor" fontSize="9">0</text>
<text x="184" y="103" textAnchor="middle" fill="currentColor" fontSize="9">90</text>
<text x="100" y="192" textAnchor="middle" fill="currentColor" fontSize="9">180</text>
<text x="14" y="103" textAnchor="middle" fill="currentColor" fontSize="9">270</text>
</g>
</svg>
<button
type="button"
aria-label={t("multiAngle.adjustVerticalUp")}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateVertical(getNextMultiAngleVertical(settings.vertical, 1))}
className="absolute left-1/2 top-3 z-40 -translate-x-1/2 rounded-md px-2 py-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
className="absolute left-1/2 top-3 z-40 flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
^
<UpOutlined className="text-[12px]" />
</button>
<button
type="button"
@ -347,9 +323,9 @@ export function MultiAngleEditor({
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateVertical(getNextMultiAngleVertical(settings.vertical, -1))}
className="absolute bottom-3 left-1/2 z-40 -translate-x-1/2 rounded-md px-2 py-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
className="absolute bottom-3 left-1/2 z-40 flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
v
<DownOutlined className="text-[12px]" />
</button>
<button
type="button"
@ -357,9 +333,9 @@ export function MultiAngleEditor({
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateHorizontal(settings.horizontal - 1)}
className="absolute left-3 top-1/2 z-40 -translate-y-1/2 rounded-md px-2 py-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
className="absolute left-3 top-1/2 z-40 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
&lt;
<LeftOutlined className="text-[12px]" />
</button>
<button
type="button"
@ -367,36 +343,48 @@ export function MultiAngleEditor({
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateHorizontal(settings.horizontal + 1)}
className="absolute right-3 top-1/2 z-40 -translate-y-1/2 rounded-md px-2 py-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
className="absolute right-3 top-1/2 z-40 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
&gt;
<RightOutlined className="text-[12px]" />
</button>
<div className="absolute inset-0 z-10 flex items-center justify-center">
{previewImage ? (
<img
src={previewImage}
alt=""
className="h-20 w-20 rounded-md border border-[var(--accent)] object-cover shadow-lg transition-transform duration-150"
style={{
transform: `perspective(420px) rotateY(${previewRotateY}deg) scale(${previewScale})`,
}}
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-md border border-dashed border-[var(--border-strong)] text-[11px] text-[var(--text-muted)]">
{t("multiAngle.noImage")}
<div className="absolute inset-0 z-10 flex items-center justify-center px-8 py-10 [perspective:720px]">
<div
className="relative h-[104px] w-[104px] transition-transform duration-150 [transform-style:preserve-3d]"
style={{
transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`,
}}
>
<div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[0_18px_42px_rgba(0,0,0,0.42)] [transform:translateZ(52px)]`}>
{previewImage ? (
<img
src={previewImage}
alt=""
className="h-full w-full rounded-xl object-cover opacity-95"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-strong)] text-[11px] text-[var(--text-muted)]">
{t("multiAngle.noImage")}
</div>
)}
</div>
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/34 [transform:rotateY(90deg)_translateZ(52px)]`}>
</div>
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/32 [transform:rotateY(-90deg)_translateZ(52px)]`}>
</div>
)}
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/30 [transform:rotateX(90deg)_translateZ(52px)]`}>
</div>
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/26 [transform:rotateX(-90deg)_translateZ(52px)]`}>
</div>
<div className={`${cubeFaceClass} bg-[var(--surface-2)]/28 [transform:rotateY(180deg)_translateZ(52px)]`}>
</div>
</div>
</div>
<svg className="pointer-events-none absolute inset-4 z-30 h-[calc(100%-2rem)] w-[calc(100%-2rem)]" viewBox="0 0 200 200" fill="none">
<line x1={cameraX} y1={cameraY} x2="100" y2={sightTargetY} stroke="#22d3ee" strokeWidth="2.4" strokeLinecap="round" />
<circle cx={cameraX} cy={cameraY} r="3.2" fill="#22d3ee" />
<g transform={`translate(${cameraX - 9} ${cameraY + 6})`}>
<rect width="18" height="12" rx="2" fill="#e5e5e5" />
<circle cx="9" cy="6" r="3" fill="#171717" />
<path d="M3 3h3" stroke="#171717" strokeWidth="1.2" strokeLinecap="round" />
</g>
</svg>
</div>
<div className={`flex min-w-0 flex-col py-1 ${compact ? "gap-3" : "gap-4"}`}>

131
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<string, unknown>;
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 | null | undefined>): 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<string, unknown> | null {
const parameters = (node?.data as { parameters?: unknown } | undefined)?.parameters;
return parameters && typeof parameters === "object" && !Array.isArray(parameters)
const data = node?.data as Record<string, unknown> | undefined;
const parameters = data?.parameters;
const baseParameters = parameters && typeof parameters === "object" && !Array.isArray(parameters)
? parameters as Record<string, unknown>
: null;
: {};
if (!data) return Object.keys(baseParameters).length > 0 ? baseParameters : null;
const legacyParameters: Record<string, unknown> = {};
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 (
<ComposerEditor
context={context}
@ -708,7 +744,7 @@ function ComposerEditor({
prompt,
activeModelDetail: undefined,
modelSchema: [],
nodeExtraTaskParams: EMPTY_EXTRA_TASK_PARAMS,
connectedInputs: null,
connectedVideoDurationSeconds: 0,
popiserverPricingMedia: {},
isHidden,
@ -742,24 +778,14 @@ function ComposerEditor({
const buildConfigForTask = useCallback((baseConfig: ComposerConfig): ComposerConfig => {
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,

36
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 (
<section
data-testid="node-inline-multi-angle-panel"
data-composer-popup-root="true"
className="nodrag nopan nowheel pointer-events-auto flex h-[300px] w-[600px] flex-col overflow-hidden rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-primary)] shadow-[var(--shadow-panel)] backdrop-blur"
className="nodrag nopan nowheel pointer-events-auto flex h-[340px] w-[600px] flex-col overflow-hidden rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-primary)] shadow-[var(--shadow-panel)] backdrop-blur"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="min-h-0 flex-1 overflow-hidden">

22
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: <ImageToolbarIcon kind="crop" />,
@ -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) && (
<MediaPreviewModal
type="image"
src={nodeData.image || displayImage || ""}

3
src/components/nodes/SmartImageNode.tsx

@ -313,6 +313,7 @@ export function SmartImageNode({ id, data, selected }: NodeProps<SmartImageNodeT
const providerEnvStatus = useWorkflowStore((state) => 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<SmartImageNodeT
return <SmartImageNeutralView id={id} data={data} selected={selected} />;
}
return <ImageInputNodeView id={id} data={data} selected={selected} />;
return <ImageInputNodeView id={id} data={data} selected={selected} renderInputHandle={isDerivedImage} />;
}

5
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;
}
/**

75
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> = {}): SelectedModel {
return {
@ -31,9 +31,20 @@ function draft(overrides: Partial<GenerationNodeConfig> = {}): GenerationNodeCon
};
}
function context(overrides: Partial<ComposerPricingContext> & { 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();
});

94
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> = {}): 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",
});
});
});

73
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",

65
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 | null | undefined>): 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,
};
}

1
src/utils/nodeHandles.ts

@ -219,6 +219,7 @@ export function normalizeConnectionHandles<T extends { sourceHandle?: string | n
export function getNodeHandleCapability(node: CapabilityNode, edges: WorkflowEdge[]): NodeHandleCapability {
if (node.type === "smartImage") {
const data = node.data as any;
if (data.derivedImage) return getStaticNodeHandles(node.type);
const mode = deriveSmartImageMode(node.id, data, edges);
return mode === "image" ? { inputs: [], outputs: [SINGLE_OUTPUT_HANDLE_ID] } : getStaticNodeHandles(node.type);
}

2
src/utils/smartImageMode.ts

@ -28,6 +28,8 @@ export function isSmartImageGenerateMode(
data: unknown,
edges: WorkflowEdge[]
): boolean {
const record = asRecord(data);
if (record.derivedImage) return false;
return hasSmartImageInputEdge(nodeId, edges) || hasSmartImageGenerationArtifact(data);
}

Loading…
Cancel
Save