Browse Source

数据修改

feature/071
spoce 2 weeks ago
parent
commit
c435367e7a
  1. 103
      src/components/__tests__/GenerationComposer.test.tsx
  2. 63
      src/components/composer/GenerationComposer.tsx
  3. 41
      src/components/media/imageCropUtils.ts
  4. 145
      src/components/nodes/GenerateImageNode.tsx
  5. 38
      src/components/nodes/ImageInputNode.tsx
  6. 35
      src/utils/__tests__/composerPricing.test.ts
  7. 4
      src/utils/composerPricing.ts

103
src/components/__tests__/GenerationComposer.test.tsx

@ -1978,6 +1978,109 @@ describe("GenerationComposer", () => {
});
});
it("uses edited local video duration for price requests before legacy node duration", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/points/quote")) {
return new Response(JSON.stringify({
success: true,
data: {
points: 88,
},
}), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
useWorkflowStore.setState({
nodes: [
videoNode("vid-1", true, {
durationSeconds: 9,
resolution: "1080p",
parameters: {
duration: 4,
},
parameterSchema: [
{
name: "duration",
type: "integer",
label: "Duration",
options: [
{ label: "4", value: 4 },
{ label: "10", value: 10 },
],
},
],
selectedModel: {
provider: "popiserver",
modelId: "51",
displayName: "Seedance Pro",
capabilities: ["text-to-video", "image-to-video"],
metadata: {
aiModelId: 51,
aiModelCode: "doubao-seedance-2-0-260128",
aiModelCodeAlias: "seedance-video-pro",
type: 2,
subType: 203,
billingMethod: 1,
},
},
}),
],
});
useModelStore.setState({
detailsById: {
"51": {
id: 51,
aiModelCode: "doubao-seedance-2-0-260128",
aiModelCodeAlias: "seedance-video-pro",
name: "Seedance Pro",
type: 2,
subType: 203,
parameters: [
{
name: "duration",
type: "integer",
label: "Duration",
options: [
{ label: "4", value: 4 },
{ label: "10", value: 10 },
],
},
],
extensionFields: [],
inputs: [],
},
},
});
render(<GenerationComposer targetNodeId="vid-1" />);
fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "10" } });
await waitFor(() => {
expect(fetchMock.mock.calls.some(([input, init]) => {
if (!String(input).startsWith("/api/points/quote")) return false;
const body = JSON.parse(String((init as RequestInit).body));
return body.payload?.parameters?.duration === 10;
})).toBe(true);
});
const estimateCall = fetchMock.mock.calls.find(([input, init]) => {
if (!String(input).startsWith("/api/points/quote")) return false;
const body = JSON.parse(String((init as RequestInit).body));
return body.payload?.parameters?.duration === 10;
});
expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({
source: "estimate",
payload: {
parameters: {
duration: 10,
output_video_duration_seconds: 10,
},
},
});
});
it("does not show PopiServer points when the model cannot be priced", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);

63
src/components/composer/GenerationComposer.tsx

@ -380,12 +380,17 @@ function uniqueReferenceVideoInputs(videos: ComposerReferenceInput[]): ComposerR
});
}
function readWorkflowNodeParameters(node: WorkflowNode | null | undefined): Record<string, unknown> | null {
function readWorkflowNodeParameters(
node: WorkflowNode | null | undefined,
baseOverride?: Record<string, unknown>
): Record<string, unknown> | null {
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>
: {};
const baseParameters = baseOverride ?? (
parameters && typeof parameters === "object" && !Array.isArray(parameters)
? parameters as Record<string, unknown>
: {}
);
if (!data) return Object.keys(baseParameters).length > 0 ? baseParameters : null;
const legacyParameters: Record<string, unknown> = {};
@ -405,11 +410,19 @@ function readWorkflowNodeParameters(node: WorkflowNode | null | undefined): Reco
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 baseDuration =
finiteNumberFromUnknown(baseParameters.duration) ??
finiteNumberFromUnknown(baseParameters.durationSeconds) ??
finiteNumberFromUnknown(baseParameters.duration_seconds);
if (baseDuration !== null && baseDuration > 0) {
if (!("output_video_duration_seconds" in baseParameters)) {
legacyParameters.output_video_duration_seconds = baseDuration;
}
} else {
const nodeDurationSeconds = finiteNumberFromUnknown(data.durationSeconds);
if (nodeDurationSeconds !== null && nodeDurationSeconds > 0) {
legacyParameters.duration = nodeDurationSeconds;
legacyParameters.output_video_duration_seconds = nodeDurationSeconds;
}
}
@ -576,8 +589,8 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
prompt: finalPrompt,
parameters: liveParameters
? {
...(config.parameters ?? {}),
...liveParameters,
...(config.parameters ?? {}),
}
: config.parameters,
};
@ -757,10 +770,6 @@ function ComposerEditor({
? state.nodes.find((candidate) => candidate.id === context.node?.id)
: undefined
);
const nodeExtraTaskParams =
(liveNode?.data as { extraTaskParams?: Record<string, unknown> } | undefined)?.extraTaskParams ||
EMPTY_EXTRA_TASK_PARAMS;
const buildConfigWithLiveNodeParameters = useCallback((baseConfig: ComposerConfig): ComposerConfig => {
if (context.mode !== "node-edit" || !context.node) return baseConfig;
const currentNode = liveNode ?? context.node;
@ -769,8 +778,8 @@ function ComposerEditor({
? {
...baseConfig,
parameters: {
...(baseConfig.parameters ?? {}),
...liveParameters,
...(baseConfig.parameters ?? {}),
},
}
: baseConfig;
@ -947,17 +956,17 @@ function ComposerEditor({
if (priceRequestIdRef.current !== requestId) return;
const detailSchema = detail?.parameters ?? inputs.modelSchema;
const liveConfig = buildConfigWithLiveNodeParameters(nextConfig);
const selectedModel = liveConfig.selectedModel;
const currentParameters = liveConfig.parameters ?? {};
const currentNode = context.mode === "node-edit" && context.node
? liveNode ?? context.node
: null;
const currentParameters = readWorkflowNodeParameters(currentNode, nextConfig.parameters ?? {}) ?? nextConfig.parameters ?? {};
const normalizedConfig: ComposerConfig = {
...liveConfig,
selectedModel,
...nextConfig,
parameters: {
...currentParameters,
...normalizeParametersBySchema(currentParameters, detailSchema),
},
extraTaskParams: liveConfig.extraTaskParams ?? {},
extraTaskParams: nextConfig.extraTaskParams ?? {},
};
const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext({
config: normalizedConfig,
@ -988,8 +997,10 @@ function ComposerEditor({
if (priceRequestIdRef.current === requestId) setPriceState({ amount: null, isLoading: false });
}
}, [
buildConfigWithLiveNodeParameters,
context.capability,
context.mode,
context.node,
liveNode,
onModelNeedsDetail,
prompt,
]);
@ -1020,10 +1031,9 @@ function ComposerEditor({
if (latest.isHidden) return;
if (suppressAutoPriceRefreshRef.current) return;
if (!latest.config.selectedModel.modelId) return;
const nextConfig = buildConfigForTask(latest.config);
void refreshPrice(nextConfig, latest.prompt, latest.activeModelDetail, latest);
void refreshPrice(latest.config, latest.prompt, latest.activeModelDetail, latest);
}, 200);
}, [buildConfigForTask, refreshPrice]);
}, [refreshPrice]);
const clearScheduledPriceRefresh = useCallback(() => {
if (priceRefreshTimerRef.current === null) return;
@ -1049,7 +1059,6 @@ function ComposerEditor({
config.selectedModel.modelId,
connectedVideoDurationSeconds,
isHidden,
nodeExtraTaskParams,
popiserverPricingMedia,
prompt,
referenceMaterialsKey,
@ -1356,7 +1365,7 @@ function ComposerEditor({
config={config}
modelSchema={modelSchema}
extensionFields={extensionFields}
extraTaskParams={nodeExtraTaskParams}
extraTaskParams={config.extraTaskParams ?? EMPTY_EXTRA_TASK_PARAMS}
canChooseModel={canChooseModel}
isProcessNode={false}
onModelNeedsDetail={onModelNeedsDetail}

41
src/components/media/imageCropUtils.ts

@ -0,0 +1,41 @@
import type { MediaCropConfirmPayload } from "@/components/media/MediaCropOverlay";
export function getCropDrawableImageSource(source: string) {
return /^https?:\/\//i.test(source) ? `/api/proxy-media?url=${encodeURIComponent(source)}` : source;
}
export function loadCropImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Image failed to load"));
image.src = getCropDrawableImageSource(source);
});
}
export async function createCroppedImageBlob(
sourceImage: string,
payload: MediaCropConfirmPayload
): Promise<Blob | null> {
const canvas = document.createElement("canvas");
canvas.width = payload.outputWidth;
canvas.height = payload.outputHeight;
const context = canvas.getContext("2d");
if (!context) return null;
const imageElement = await loadCropImage(sourceImage);
context.drawImage(
imageElement,
payload.cropRect.x,
payload.cropRect.y,
payload.cropRect.width,
payload.cropRect.height,
0,
0,
payload.outputWidth,
payload.outputHeight
);
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
}

145
src/components/nodes/GenerateImageNode.tsx

@ -30,8 +30,19 @@ import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHan
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPayload } from "@/components/media/MediaCropOverlay";
import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar";
import { createCroppedImageBlob } from "@/components/media/imageCropUtils";
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload";
const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600];
const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [
{ key: MEDIA_EDIT_TOOL.crop, label: "裁剪" },
{ key: MEDIA_EDIT_TOOL.splitGrid, label: "九宫格" },
{ key: MEDIA_EDIT_TOOL.repaint, label: "重绘" },
{ key: MEDIA_EDIT_TOOL.erase, label: "擦除" },
{ key: MEDIA_EDIT_TOOL.cutout, label: "抠图" },
];
function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean {
return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId);
@ -55,6 +66,10 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id);
const outputImageSrc = adaptiveOutputImage;
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);
const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference);
const popiserverImageModels = useModelStore((state) => state.byKind.image);
@ -62,10 +77,18 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [isCropping, setIsCropping] = useState(false);
const [activeMediaTool, setActiveMediaTool] = useState<MediaEditTool>(MEDIA_EDIT_TOOL.crop);
const [cropAspectRatio, setCropAspectRatio] = useState<MediaCropAspectRatio>("original");
const [cropPayload, setCropPayload] = useState<MediaCropConfirmPayload | null>(null);
const [isCropUploading, setIsCropUploading] = useState(false);
const [cropImageDimensions, setCropImageDimensions] = useState<{ width: number; height: number } | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
setDisplayFallbackImage(null);
setImageRetryNonce(0);
setCropImageDimensions(null);
}, [preferredDisplayImage]);
// Register browse callback for floating header button
@ -166,6 +189,65 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
});
}, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateNodeData]);
const handleConfirmCrop = useCallback(async (payload: MediaCropConfirmPayload) => {
const sourceImage = nodeData.outputImage;
if (!sourceImage) return;
let blob: Blob | null = null;
try {
blob = await createCroppedImageBlob(sourceImage, payload);
} catch {
alert(t("imageInput.cropFailed"));
return;
}
if (!blob) {
alert(t("imageInput.cropFailed"));
return;
}
setIsCropUploading(true);
try {
const filename = `crop-${Date.now()}.png`;
const uploaded = await uploadBlobToGatewayMedia(blob, filename, "image/png", "image");
const currentNode = getNodeById(id);
const currentPosition = currentNode?.position ?? { x: 0, y: 0 };
const currentWidth = typeof currentNode?.width === "number"
? currentNode.width
: typeof currentNode?.style?.width === "number"
? currentNode.style.width
: 300;
const nextNodeId = addNode("smartImage", {
x: currentPosition.x + currentWidth + 220,
y: currentPosition.y,
}, {
image: uploaded.url,
imageRef: undefined,
previewImage: uploaded.previewUrl,
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);
} catch (error) {
alert(error instanceof Error ? error.message : t("imageInput.cropFailed"));
} finally {
setIsCropUploading(false);
}
}, [addNode, getNodeById, id, nodeData.outputImage, onConnect, selectSingleNode, t]);
const handleCarouselPrevious = useCallback(async () => {
const history = nodeData.imageHistory || [];
if (history.length === 0 || isLoadingCarouselImage) return;
@ -290,8 +372,28 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
const selectedNodeCount = useSelectedNodeCount();
const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && nodeData.outputImage);
const renderedImageSrc = isCropping ? nodeData.outputImage : outputImageSrc;
const selectedActions: NodeActionCapsuleAction[] = nodeData.outputImage
? [
{
key: "crop",
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v12a3 3 0 0 0 3 3h12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 6h12a3 3 0 0 1 3 3v12" />
</svg>
),
label: t("imageInput.crop"),
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setActiveMediaTool(MEDIA_EDIT_TOOL.crop);
setCropAspectRatio("original");
setCropPayload(null);
setCropImageDimensions(null);
setIsCropping(true);
},
},
{
key: "multi-angle",
icon: (
@ -392,7 +494,26 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
handleType="image"
/>
{showSelectedActions && <NodeActionCapsule actions={selectedActions} />}
{showSelectedActions && !isCropping && <NodeActionCapsule actions={selectedActions} />}
{isCropping && (
<MediaEditToolbar
activeTool={activeMediaTool}
tools={IMAGE_MEDIA_EDIT_TOOLS}
busy={isCropUploading}
onCancel={() => setIsCropping(false)}
context={{
value: cropAspectRatio,
onChange: setCropAspectRatio,
onConfirm: () => {
if (cropPayload) void handleConfirmCrop(cropPayload);
},
}}
toolComponents={{
[MEDIA_EDIT_TOOL.crop]: CropRatioSelect,
}}
/>
)}
<div
className="relative w-full h-full min-h-0 overflow-hidden rounded-lg"
@ -402,10 +523,17 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
{displayImage ? (
<>
<img
ref={imageRef}
key={`${displayImage ?? "empty"}-${imageRetryNonce}`}
src={outputImageSrc ?? undefined}
src={renderedImageSrc ?? undefined}
alt="Generated"
onLoad={() => setImageRetryNonce(0)}
onLoad={(event) => {
setImageRetryNonce(0);
const image = event.currentTarget;
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
setCropImageDimensions({ width: image.naturalWidth, height: image.naturalHeight });
}
}}
onError={() => {
if (nodeData.previewImg && displayImage === nodeData.previewImg && nodeData.outputImage && nodeData.outputImage !== nodeData.previewImg) {
setDisplayFallbackImage(nodeData.outputImage);
@ -428,6 +556,17 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
}}
className="w-full h-full object-contain cursor-zoom-in"
/>
{isCropping && cropImageDimensions && (
<MediaCropOverlay
kind="image"
mediaElementRef={imageRef}
naturalWidth={cropImageDimensions.width}
naturalHeight={cropImageDimensions.height}
aspectRatio={cropAspectRatio}
busy={isCropUploading}
onCropChange={setCropPayload}
/>
)}
{/* Loading overlay for generation */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center">

38
src/components/nodes/ImageInputNode.tsx

@ -24,6 +24,7 @@ import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCap
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPayload } from "@/components/media/MediaCropOverlay";
import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar";
import { createCroppedImageBlob } from "@/components/media/imageCropUtils";
type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">;
const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput;
@ -37,20 +38,6 @@ const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [
{ key: MEDIA_EDIT_TOOL.cutout, label: "抠图" },
];
function getCropDrawableImageSource(source: string) {
return /^https?:\/\//i.test(source) ? `/api/proxy-media?url=${encodeURIComponent(source)}` : source;
}
function loadCropImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Image failed to load"));
image.src = getCropDrawableImageSource(source);
});
}
export interface ImageInputNodeViewProps {
id: string;
data: ImageInputNodeData;
@ -309,30 +296,9 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
const sourceImage = nodeData.image || displayImage;
if (!sourceImage) return;
const canvas = document.createElement("canvas");
canvas.width = payload.outputWidth;
canvas.height = payload.outputHeight;
const context = canvas.getContext("2d");
if (!context) {
alert(t("imageInput.cropFailed"));
return;
}
let blob: Blob | null = null;
try {
const imageElement = await loadCropImage(sourceImage);
context.drawImage(
imageElement,
payload.cropRect.x,
payload.cropRect.y,
payload.cropRect.width,
payload.cropRect.height,
0,
0,
payload.outputWidth,
payload.outputHeight
);
blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
blob = await createCroppedImageBlob(sourceImage, payload);
} catch {
alert(t("imageInput.cropFailed"));
return;

35
src/utils/__tests__/composerPricing.test.ts

@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildPopiserverPriceQuoteRequest,
buildPopiserverPointsEstimatePayload,
getQuotedPointAmount,
} from "@/utils/composerPricing";
import type { SelectedModel } from "@/types";
@ -42,7 +41,7 @@ function context(overrides: Partial<ComposerPricingContext> & { config?: Generat
describe("composerPricing", () => {
it("builds PopiServer estimate payloads with provider model aliases", () => {
const payload = buildPopiserverPointsEstimatePayload(context({
const request = buildPopiserverPriceQuoteRequest(context({
inputVideoDurationSeconds: 12,
config: draft({
selectedModel: model({
@ -56,7 +55,9 @@ describe("composerPricing", () => {
}),
}));
expect(payload).toEqual({
expect(request).toEqual({
source: "estimate",
payload: {
model_name: "video-alias",
estimation_type: "auto",
parameters: {
@ -66,6 +67,7 @@ describe("composerPricing", () => {
},
imageCount: 1,
input_video_duration_seconds: 12,
},
});
});
@ -83,13 +85,19 @@ describe("composerPricing", () => {
});
it("uses PopiServer estimate only for billingMethod 2 or Seedance models", () => {
expect(buildPopiserverPointsEstimatePayload(context({ config: draft({
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({ billingMethod: 2 }),
}) }))).not.toBeNull();
expect(buildPopiserverPointsEstimatePayload(context({ config: draft({
selectedModel: model({ billingMethod: 1 }),
}) }))).toBeNull();
expect(buildPopiserverPointsEstimatePayload(context({
}) }))).toMatchObject({ source: "estimate" });
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({
billingMethod: 1,
aiModelId: 32,
aiModelCode: "image-code",
type: 1,
subType: 101,
}),
}) }))).toMatchObject({ source: "task-price" });
expect(buildPopiserverPriceQuoteRequest(context({
capability: "video",
config: draft({
selectedModel: model({
@ -97,18 +105,19 @@ describe("composerPricing", () => {
billingMethod: 1,
}),
}),
}))).not.toBeNull();
}))).toMatchObject({ source: "estimate" });
});
it("builds unified PopiServer quote requests", () => {
const estimateContext = context({ config: draft({
selectedModel: model({ billingMethod: 2 }),
}) });
const pointsPayload = buildPopiserverPointsEstimatePayload(estimateContext);
expect(buildPopiserverPriceQuoteRequest(estimateContext)).toEqual({
expect(buildPopiserverPriceQuoteRequest(estimateContext)).toMatchObject({
source: "estimate",
payload: pointsPayload,
payload: {
estimation_type: "auto",
},
});
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({

4
src/utils/composerPricing.ts

@ -84,7 +84,7 @@ function getPopiserverModelName(model: SelectedModel): string {
);
}
export function buildPopiserverPointsEstimatePayload(
function buildPopiserverPointsEstimatePayload(
context: ComposerPricingContext
): PopiserverPointsEstimatePayload | null {
const { config, capability, inputVideoDurationSeconds, media } = context;
@ -142,7 +142,7 @@ export function getQuotedPointAmount(response: PopiserverPriceQuoteResponse): nu
return finiteNumberFromUnknown(response.data?.points);
}
export function buildComposerPopiserverTaskPricePayload(
function buildComposerPopiserverTaskPricePayload(
context: ComposerPricingContext
) {
const { config, capability, media } = context;

Loading…
Cancel
Save