Browse Source

参数统一化

feature/071
yun 2 weeks ago
parent
commit
4f01833d3b
  1. 3
      src/app/api/_gatewayLogging.ts
  2. 88
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  3. 25
      src/app/api/generate/providers/popiserver.ts
  4. 2
      src/app/api/generate/task/create/route.ts
  5. 31
      src/components/__tests__/GenerationComposer.test.tsx
  6. 25
      src/components/composer/GenerationComposer.tsx
  7. 29
      src/components/composer/NodeMultiAnglePanel.tsx
  8. 2
      src/lib/popiartTask/runTask.ts
  9. 2
      src/lib/providers/types.ts
  10. 23
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  11. 9
      src/store/execution/generate3dExecutor.ts
  12. 9
      src/store/execution/generateAudioExecutor.ts
  13. 36
      src/store/execution/generateVideoExecutor.ts
  14. 9
      src/store/execution/nanoBananaExecutor.ts
  15. 11
      src/store/execution/popiMediaGenerationExecutor.ts
  16. 1
      src/store/execution/popiartTaskClient.ts
  17. 40
      src/utils/__tests__/generationConfig.test.ts
  18. 17
      src/utils/generationConfig.ts
  19. 4
      src/utils/popiserverTaskPayload.ts

3
src/app/api/_gatewayLogging.ts

@ -4,6 +4,7 @@ import { createHash } from "crypto";
const SENSITIVE_KEY_PATTERN = /authorization|(^|[-_])token($|[-_])|token$|api[-_]?key|apikey|secret|password/i;
const MAX_STRING_LENGTH = 500;
const VERBOSE_GATEWAY_ORIGIN = "https://wwwtest.popi.art";
const IGNORED_GATEWAY_PATHS = new Set(["/api_client/canvas/update"]);
type JsonLike =
| null
@ -148,6 +149,7 @@ export function logGatewayRequest(
context?: Record<string, unknown>
): void {
const { interfaceName, origin, queryParams, isVerboseGateway } = getUrlParts(url);
if (IGNORED_GATEWAY_PATHS.has(interfaceName)) return;
const headers = sanitizeHeaders(init.headers);
const bodyParams = sanitizeBody(init.body);
const passedParams = {
@ -178,6 +180,7 @@ export async function logGatewayResponse(
startedAt?: number
): Promise<void> {
const { interfaceName, origin, isVerboseGateway } = getUrlParts(url);
if (IGNORED_GATEWAY_PATHS.has(interfaceName)) return;
const responseBody = await sanitizeResponseBody(response);
const durationMs = startedAt ? Date.now() - startedAt : undefined;

88
src/app/api/generate/providers/__tests__/popiserver.test.ts

@ -75,6 +75,7 @@ describe("popiserver generation provider", () => {
afterEach(() => {
global.fetch = originalFetch;
process.env.POPIART_API_BASE_URL = originalPopiartApiBaseUrl;
vi.unstubAllEnvs();
});
it("submits a unified async task create request", async () => {
@ -111,6 +112,24 @@ describe("popiserver generation provider", () => {
expect(body.aspectRatio).toBeUndefined();
});
it("returns the prepared PopiServer task request when gateway skip is enabled", async () => {
vi.stubEnv("SKIP_GENERATION_GATEWAY", "true");
let error: unknown;
try {
await submitPopiTask(makeRequest(), "login-token", makeInput());
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = error instanceof Error ? error.message : "";
expect(message).toContain("Gateway request preview");
expect(message).toContain("/api_client/anime/task/create");
expect(message).toContain('"aiModelCode": "viduq2-pro"');
expect(vi.mocked(global.fetch)).not.toHaveBeenCalled();
});
it("uses top-level task routing fields and drops legacy parameter routing fields", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
@ -142,6 +161,48 @@ describe("popiserver generation provider", () => {
});
});
it("flattens model parameters and keeps extra task params nested in PopiServer body", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
status: "0000",
message: "ok",
data: { id: 1197 },
}),
} as Response);
const input = makeInput();
input.parameters = {
ratio: "16:9",
resolution: "std",
duration: 5,
};
input.extraTaskParams = {
generate_audio: true,
};
input.referenceSubjectList = [
{ id: 1, type: "image", url: "https://example.com/1.png", name: "图1" },
];
await submitPopiTask(makeRequest(), "login-token", input);
const [, init] = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({
ratio: "16:9",
resolution: "std",
duration: 5,
extraTaskParams: {
generate_audio: true,
},
referenceSubjectList: [
{ id: 1, type: "image", url: "https://example.com/1.png", name: "图1" },
{ id: 2, type: "image", url: "https://example.com/2.png", name: "图2" },
],
});
expect(body.generate_audio).toBeUndefined();
});
it("collects all task ids from batched task create responses", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
@ -828,6 +889,33 @@ describe("popiserver generation provider", () => {
expect(result.status).toBe("processing");
});
it("returns UserErrorTipMsg from failed PopiServer tasks", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
status: "0000",
message: "ok",
data: {
list: [
{
id: 1193,
status: 3,
ErrorMsg: "raw provider error",
UserErrorTipMsg: "Please revise the prompt and try again.",
},
],
},
}),
} as Response);
const result = await checkPopiTaskOnce(makeRequest(), "login-token", "1193", "image");
expect(result).toEqual({
status: "failed",
error: "Please revise the prompt and try again.",
});
});
it("does not include reference images from completed image tasks", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,

25
src/app/api/generate/providers/popiserver.ts

@ -18,6 +18,7 @@ import { buildPopiTaskBody as buildSharedPopiTaskBody } from "@/utils/popiserver
const POPISERVER_TASK_CREATE_PATH = "/api_client/anime/task/create";
const POPISERVER_TASK_LIST_PATH = "/api_client/anime/task/list";
const POPISERVER_MEDIA_UPLOAD_PATH = "/api_client/media/upload";
const SKIPPED_GATEWAY_PREVIEW_MAX_LENGTH = 12000;
type PopiserverResponse<T> = {
status?: string;
@ -68,7 +69,9 @@ type PopiTask = {
subType?: number;
status?: number;
errorMsg?: string;
ErrorMsg?: string;
userErrorTipMsg?: string;
UserErrorTipMsg?: string;
images?: string[] | null;
voice?: string | null;
resultList?: PopiTaskResult[] | null;
@ -79,6 +82,16 @@ export type PopiTaskPollResult =
| { status: "failed"; error: string }
| { status: "completed"; output: GenerationOutput };
function buildSkippedGatewayPreviewMessage(provider: string, request: { method: string; url: string; body: unknown }): string {
const message = skippedGenerationGatewayMessage(provider);
const preview = JSON.stringify(request, null, 2);
const visiblePreview =
preview.length > SKIPPED_GATEWAY_PREVIEW_MAX_LENGTH
? `${preview.slice(0, SKIPPED_GATEWAY_PREVIEW_MAX_LENGTH)}\n...[truncated:${preview.length}]`
: preview;
return `${message}\n\nGateway request preview:\n${visiblePreview}`;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
@ -120,6 +133,10 @@ function getTaskList(data: PopiTaskListData | undefined): PopiTask[] {
return Array.isArray(data) ? data : data.list ?? [];
}
function getTaskError(task: PopiTask): string | undefined {
return task.userErrorTipMsg || task.UserErrorTipMsg || task.errorMsg || task.ErrorMsg;
}
function mediaResourceUrl(media: PopiUploadedMedia): string | null {
return media.url || media.originUrl || media.path || media.originPath || null;
}
@ -369,7 +386,11 @@ export async function submitPopiTask(
modelName: input.model.name,
}
);
throw ApiError.provider(skippedGenerationGatewayMessage("PopiServer"));
throw ApiError.provider(buildSkippedGatewayPreviewMessage("PopiServer", {
method: init.method || "POST",
url,
body,
}));
}
const data = await fetchPopiJson<PopiTaskCreateData>(
@ -457,7 +478,7 @@ export async function checkPopiTaskOnce(
const tasks = getTaskList(body.data);
if (tasks.length === 0) return { status: "processing" };
for (const task of tasks) {
const error = task.userErrorTipMsg || task.errorMsg;
const error = getTaskError(task);
if (error) return { status: "failed", error };
}
if (tasks.every((task) => task.status === 2 && Array.isArray(task.resultList) && task.resultList.length > 0)) {

2
src/app/api/generate/task/create/route.ts

@ -12,6 +12,7 @@ export const maxDuration = 60;
type TaskCreateRequest = GenerateRequest & {
selectedModel?: SelectedModel;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
referenceSubjectList?: unknown;
batchSize?: number;
audioVoiceId?: string;
@ -56,6 +57,7 @@ export async function POST(request: NextRequest) {
videos: body.videos ? [...body.videos] : [],
voices: body.voices ? [...body.voices] : [],
parameters: body.parameters,
extraTaskParams: body.extraTaskParams,
type: body.type,
subType: body.subType,
referenceSubjectList: body.referenceSubjectList,

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

@ -1614,8 +1614,9 @@ describe("GenerationComposer", () => {
render(<GenerationComposer />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /36/ })).toBeInTheDocument();
expect(screen.getByText("36 points")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument();
const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote"));
expect(priceCall).toBeDefined();
expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({
@ -1626,10 +1627,6 @@ describe("GenerationComposer", () => {
aiModelId: 32,
aiModelCode: "gpt-image-2-all",
aiModelCodeAlias: "gpt-image-2-all",
ratio: "16:9",
resolution: "2K",
width: 1280,
height: 720,
batchSize: 4,
},
});
@ -1910,18 +1907,16 @@ describe("GenerationComposer", () => {
render(<GenerationComposer />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /66 (points|积分)/ })).toBeInTheDocument();
expect(screen.getByText("66 points")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument();
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote"));
expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({
source: "estimate",
payload: {
model_name: "token-image-model",
parameters: {
resolution: "2K",
aspect_ratio: "16:9",
},
parameters: {},
},
});
});
@ -2116,17 +2111,19 @@ describe("GenerationComposer", () => {
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
});
it("submits from the collapsed generate button without expanding", async () => {
it("expands from the collapsed summary without submitting", async () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
fireEvent.click(getCollapseButton());
fireEvent.click(getGenerateButton());
await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1");
await act(async () => {
fireEvent.click(getCollapseButton());
});
expect(queryPromptEditor("visible prompt")).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByText("visible prompt"));
});
expect(useWorkflowStore.getState().regenerateNode).not.toHaveBeenCalled();
expectPromptEditorValue("visible prompt");
});
it("uses the llm model filter and reruns the selected LLM node from the bottom composer", async () => {

25
src/components/composer/GenerationComposer.tsx

@ -1051,7 +1051,8 @@ function ComposerEditor({
const modelPointCostLabel = dynamicEstimatedPointAmount !== null
? t("composer.pointCost", { count: formatPointAmount(dynamicEstimatedPointAmount) })
: null;
const submitButtonLabel = isCurrentNodeRunning ? "Cancel task" : modelPointCostLabel ?? (isPriceLoading ? "Loading" : t("composer.generate"));
const submitButtonLabel = isCurrentNodeRunning ? "Cancel task" : t("composer.generate");
const priceLabel = modelPointCostLabel ?? (isPriceLoading ? "..." : null);
const hasComposerMediaHeader =
isProcessNode || connectedTextItems.length > 0 || referenceMaterials.length > 0 || hasVideoSubTypes;
@ -1276,7 +1277,9 @@ function ComposerEditor({
<span className="min-w-0 flex-1 truncate text-sm text-[var(--text-primary)]">
{prompt || config.selectedModel.displayName || t("composer.describeContent")}
</span>
<span className="shrink-0 text-xs text-[var(--text-muted)]">{submitButtonLabel}</span>
<span className="shrink-0 text-xs text-[var(--text-muted)]">
{isCurrentNodeRunning ? submitButtonLabel : priceLabel ?? submitButtonLabel}
</span>
</button>
) : (
<>
@ -1376,13 +1379,23 @@ function ComposerEditor({
)}
<div className="ml-auto flex items-center gap-2">
{priceLabel ? (
<div
className="flex h-9 items-center gap-1 text-xs font-medium text-[var(--text-muted)]"
aria-live="polite"
title={modelPointCostLabel ?? "Loading points"}
>
<svg className="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 4 14h6l-1 8 10-13h-6l0-7Z" />
</svg>
<span>{priceLabel}</span>
</div>
) : null}
<button
type="button"
onClick={runAction}
disabled={!canClickSubmit}
className={modelPointCostLabel && !isCurrentNodeRunning
? "ml-1 flex h-9 min-w-[7.25rem] items-center justify-center gap-2 rounded-lg bg-[var(--accent)] px-3 text-xs font-medium text-[var(--text-on-overlay)] transition-colors hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
: "ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-secondary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-primary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"}
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
aria-label={submitButtonLabel}
title={submitButtonLabel}
>
@ -1390,8 +1403,6 @@ function ComposerEditor({
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
</svg>
) : modelPointCostLabel ? (
<span>{modelPointCostLabel}</span>
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M12 19V5" />

29
src/components/composer/NodeMultiAnglePanel.tsx

@ -16,6 +16,7 @@ import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
import { executePopiartTask } from "@/store/execution/popiartTaskClient";
import { createDerivedImageNode } from "@/utils/derivedImageNodes";
import { toSelectedModel } from "@/utils/selectedModel";
import { getImageDimensions } from "@/utils/nodeDimensions";
interface NodeMultiAnglePanelProps {
node: WorkflowNode;
@ -28,6 +29,8 @@ type GeneratedImageResult = {
previewImage?: string;
};
type ImageDimensions = { width: number; height: number };
function normalizeGeneratedImage(value: unknown): GeneratedImageResult | null {
if (typeof value === "string" && value.length > 0) {
return { image: value };
@ -50,6 +53,26 @@ function readFirstGeneratedImage(result: GenerateResponse): GeneratedImageResult
return normalizeGeneratedImage(result.image);
}
function isValidImageDimensions(value: unknown): value is ImageDimensions {
if (!value || typeof value !== "object") return false;
const dimensions = value as { width?: unknown; height?: unknown };
return (
typeof dimensions.width === "number" &&
typeof dimensions.height === "number" &&
Number.isFinite(dimensions.width) &&
Number.isFinite(dimensions.height) &&
dimensions.width > 0 &&
dimensions.height > 0
);
}
async function readCurrentImageDimensions(node: WorkflowNode, imageSource: string): Promise<ImageDimensions | null> {
if (isValidImageDimensions((node.data as { dimensions?: unknown }).dimensions)) {
return (node.data as { dimensions: ImageDimensions }).dimensions;
}
return getImageDimensions(imageSource);
}
export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAnglePanelProps) {
const { t } = useI18n();
const addNode = useWorkflowStore((state) => state.addNode);
@ -101,13 +124,17 @@ export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAng
: typeof node.style?.width === "number"
? node.style.width
: 300;
const dimensions = await readCurrentImageDimensions(node, imageSource);
const result = await executePopiartTask(
{
images: [imageSource],
prompt: "multi-angle image generation",
prompt: "",
selectedModel,
parameters: {
batchSize: 1,
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
},
extraTaskParams: {
horizontal_angle: settings.horizontal,
vertical_angle: settings.vertical,
zoom: settings.zoom,

2
src/lib/popiartTask/runTask.ts

@ -11,6 +11,7 @@ export type PopiartTaskPayload = {
prompt: string;
selectedModel: SelectedModel;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
type?: number;
subType?: number;
referenceSubjectList?: unknown;
@ -88,6 +89,7 @@ async function serializeTaskCreatePayload(payload: PopiartTaskPayload): Promise<
...payload,
...builtInFields,
parameters: sanitizeModelParameters(payload.parameters),
extraTaskParams: sanitizeModelParameters(payload.extraTaskParams),
referenceSubjectList: normalizeReferenceSubjectListIds(payload.referenceSubjectList),
selectedModel: omitSelectedModelProvider(payload.selectedModel),
});

2
src/lib/providers/types.ts

@ -110,6 +110,8 @@ export interface GenerationInput {
voices?: string[];
/** Model-specific parameters (varies by provider/model) */
parameters?: Record<string, unknown>;
/** Provider task-level extension parameters, kept separate from model parameters until provider submission. */
extraTaskParams?: Record<string, unknown>;
/** Popi task type, kept outside model-specific parameters. */
type?: number;
/** Popi task subtype, kept outside model-specific parameters. */

23
src/store/execution/__tests__/generateVideoExecutor.test.ts

@ -116,7 +116,8 @@ async function withProviderMode<T>(mode: "multi" | "popi", run: () => Promise<T>
}
describe("executeGenerateVideo", () => {
it("should throw when missing required inputs", async () => {
it("submits the task when inputs are empty", async () => {
mockPopiTaskSuccess();
const node = makeNode();
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
@ -129,7 +130,12 @@ describe("executeGenerateVideo", () => {
}),
});
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Missing required inputs");
await executeGenerateVideo(ctx);
expect(mockFetch).toHaveBeenCalledWith(
"/api/generate/task/create",
expect.objectContaining({ method: "POST" })
);
});
it("should throw when no model selected", async () => {
@ -498,16 +504,15 @@ describe("executeGenerateVideo", () => {
});
});
it("rejects video duration shorter than 4 seconds", async () => {
it("submits video durations without local validation", async () => {
mockPopiTaskSuccess();
const node = makeNode({ durationSeconds: 3 });
const ctx = makeCtx(node);
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Video duration must be at least 4 seconds");
expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", {
status: "error",
error: "Video duration must be at least 4 seconds",
});
expect(mockFetch).not.toHaveBeenCalled();
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.parameters).toMatchObject({ duration: 3 });
});
it("uses the selected video model even when legacy fallback fields are present", async () => {

9
src/store/execution/generate3dExecutor.ts

@ -8,7 +8,11 @@
import type { Generate3DNodeData, SelectedModel } from "@/types";
import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
import { executePopiartTask } from "./popiartTaskClient";
import { buildRuntimeGenerationParameters, type GenerationNodeConfig } from "@/utils/generationConfig";
import {
buildRuntimeExtraTaskParams,
buildRuntimeModelParameters,
type GenerationNodeConfig,
} from "@/utils/generationConfig";
import type { NodeExecutionContext } from "./types";
import { buildPromptFromConnectedTextItems } from "./textInputPrompt";
import { getPopiSupportedMediaInputs } from "./popiMediaSupport";
@ -120,7 +124,8 @@ export async function executeGenerate3D(
images: supportedInputs.images,
prompt: promptText || "",
selectedModel: modelToUse,
parameters: buildRuntimeGenerationParameters(generationConfig),
parameters: buildRuntimeModelParameters(generationConfig),
extraTaskParams: buildRuntimeExtraTaskParams(generationConfig),
referenceSubjectList: nodeData.referenceSubjectList,
mediaType: "3d" as const,
};

9
src/store/execution/generateAudioExecutor.ts

@ -5,7 +5,11 @@
import type { GenerateAudioNodeData } from "@/types";
import { createPopiserverDefaultAudioModel } from "@/store/utils/defaultImageModel";
import { buildRuntimeGenerationParameters, readAudioGenerationConfig } from "@/utils/generationConfig";
import {
buildRuntimeExtraTaskParams,
buildRuntimeModelParameters,
readAudioGenerationConfig,
} from "@/utils/generationConfig";
import type { NodeExecutionContext } from "./types";
import { buildPromptFromConnectedTextItems } from "./textInputPrompt";
import {
@ -50,7 +54,8 @@ export async function executeGenerateAudio(
mediaType: "audio",
model,
prompt,
parameters: buildRuntimeGenerationParameters({ ...nodeConfig, prompt }),
parameters: buildRuntimeModelParameters({ ...nodeConfig, prompt }),
extraTaskParams: buildRuntimeExtraTaskParams({ ...nodeConfig, prompt }),
referenceSubjectList: nodeConfig.referenceSubjectList,
});

36
src/store/execution/generateVideoExecutor.ts

@ -5,13 +5,15 @@
import type { GenerateVideoNodeData } from "@/types";
import { createPopiserverDefaultVideoModel } from "@/store/utils/defaultImageModel";
import { isValidVideoDurationSeconds } from "@/utils/videoGenerationSettings";
import { buildRuntimeGenerationParameters, readVideoGenerationConfig } from "@/utils/generationConfig";
import {
buildRuntimeExtraTaskParams,
buildRuntimeModelParameters,
readVideoGenerationConfig,
} from "@/utils/generationConfig";
import { isHttpMediaUrl } from "@/utils/mediaResolver";
import type { NodeExecutionContext } from "./types";
import { buildPromptFromConnectedTextItems } from "./textInputPrompt";
import {
assertPopiMediaModel,
executePopiMediaGeneration,
mediaGenerationErrorMessage,
} from "./popiMediaGenerationExecutor";
@ -28,10 +30,6 @@ function getGeneratedImageUrl(value: unknown): string | undefined {
return typeof record.url === "string" && record.url.length > 0 ? record.url : undefined;
}
function readVideoDurationSeconds(parameters: Record<string, unknown>): unknown {
return parameters.duration ?? parameters.duration_seconds ?? parameters.durationSeconds;
}
export async function executeGenerateVideo(
ctx: NodeExecutionContext,
options: GenerateVideoOptions = {}
@ -51,19 +49,8 @@ export async function executeGenerateVideo(
const nodeConfig = readVideoGenerationConfig(nodeData, createPopiserverDefaultVideoModel());
const images = useStoredFallback && connectedImages.length === 0 ? nodeConfig.inputImages : connectedImages;
const prompt = buildPromptFromConnectedTextItems(connectedTextItems, connectedText, nodeConfig.prompt);
const hasPrompt = Boolean(prompt);
const hasAudio = connectedAudio.length > 0;
const hasVideo = connectedVideos.length > 0;
try {
if (!isValidVideoDurationSeconds(readVideoDurationSeconds(nodeConfig.parameters))) {
throw new Error("Video duration must be at least 4 seconds");
}
if (!hasPrompt && images.length === 0 && !hasVideo && !hasAudio) {
throw new Error("Missing required inputs");
}
const model = assertPopiMediaModel(nodeConfig.selectedModel);
updateNodeData(node.id, {
inputImages: images,
inputPrompt: nodeConfig.prompt,
@ -74,12 +61,13 @@ export async function executeGenerateVideo(
const result = await executePopiMediaGeneration({
ctx,
mediaType: "video",
model,
model: nodeConfig.selectedModel,
prompt: prompt || "",
images,
videos: connectedVideos,
voices: connectedAudio,
parameters: buildRuntimeGenerationParameters({ ...nodeConfig, prompt: prompt || "" }),
parameters: buildRuntimeModelParameters({ ...nodeConfig, prompt: prompt || "" }),
extraTaskParams: buildRuntimeExtraTaskParams({ ...nodeConfig, prompt: prompt || "" }),
subType: nodeConfig.subType,
referenceSubjectList: nodeConfig.referenceSubjectList,
});
@ -98,9 +86,9 @@ export async function executeGenerateVideo(
video: outputContent,
timestamp,
prompt: prompt || "",
model: model.modelId,
modelDisplayName: model.displayName || model.modelId,
modelProvider: model.provider,
model: nodeConfig.selectedModel.modelId,
modelDisplayName: nodeConfig.selectedModel.displayName || nodeConfig.selectedModel.modelId,
modelProvider: nodeConfig.selectedModel.provider,
};
const isRemoteVideo = Boolean(videoData && outputContent && isHttpMediaUrl(outputContent));
@ -113,7 +101,7 @@ export async function executeGenerateVideo(
error: null,
videoHistory: [newHistoryItem, ...(nodeData.videoHistory || [])].slice(0, 50),
selectedVideoHistoryIndex: 0,
lastUsedModel: model,
lastUsedModel: nodeConfig.selectedModel,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {

9
src/store/execution/nanoBananaExecutor.ts

@ -5,7 +5,11 @@
import type { NanoBananaNodeData } from "@/types";
import { createPopiserverDefaultImageModel } from "@/store/utils/defaultImageModel";
import { buildRuntimeGenerationParameters, readImageGenerationConfig } from "@/utils/generationConfig";
import {
buildRuntimeExtraTaskParams,
buildRuntimeModelParameters,
readImageGenerationConfig,
} from "@/utils/generationConfig";
import { isHttpMediaUrl } from "@/utils/mediaResolver";
import type { NodeExecutionContext } from "./types";
import { buildPromptFromConnectedTextItems } from "./textInputPrompt";
@ -105,7 +109,8 @@ export async function executeNanoBanana(
model,
prompt,
images,
parameters: buildRuntimeGenerationParameters({ ...nodeConfig, prompt }),
parameters: buildRuntimeModelParameters({ ...nodeConfig, prompt }),
extraTaskParams: buildRuntimeExtraTaskParams({ ...nodeConfig, prompt }),
referenceSubjectList: nodeConfig.referenceSubjectList,
});

11
src/store/execution/popiMediaGenerationExecutor.ts

@ -1,7 +1,6 @@
import type { GenerateResponse, SelectedModel } from "@/types";
import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
import { uploadPopiserverGenerationInputsForGeneration } from "@/utils/temporaryImageUpload";
import { getPopiSupportedMediaInputs } from "./popiMediaSupport";
import { executePopiartTask } from "./popiartTaskClient";
import type { NodeExecutionContext } from "./types";
@ -22,6 +21,7 @@ export interface ExecutePopiMediaGenerationOptions {
videos?: string[];
voices?: string[];
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
subType?: number;
referenceSubjectList?: unknown;
prepareParameters?: (parameters: Record<string, unknown>, inputs: PopiPreparedMediaInputs) => Record<string, unknown> | undefined;
@ -59,17 +59,17 @@ export async function executePopiMediaGeneration({
videos = [],
voices = [],
parameters,
extraTaskParams,
subType,
referenceSubjectList,
prepareParameters,
}: ExecutePopiMediaGenerationOptions): Promise<GenerateResponse> {
const selectedModel = assertPopiMediaModel(model);
const preparedImages = await uploadPopiserverGenerationInputsForGeneration(images, {});
const inputs = getPopiSupportedMediaInputs(selectedModel, {
const inputs = {
images: preparedImages.images,
videos,
voices,
});
};
const effectiveParameters = prepareParameters ? prepareParameters(parameters ?? {}, inputs) : parameters;
return executePopiartTask(
{
@ -78,8 +78,9 @@ export async function executePopiMediaGeneration({
videos: inputs.videos,
voices: inputs.voices,
prompt,
selectedModel,
selectedModel: model,
...(effectiveParameters !== undefined ? { parameters: effectiveParameters } : {}),
...(extraTaskParams !== undefined ? { extraTaskParams } : {}),
...(subType !== undefined ? { subType } : {}),
...(referenceSubjectList !== undefined ? { referenceSubjectList } : {}),
mediaType,

1
src/store/execution/popiartTaskClient.ts

@ -9,6 +9,7 @@ type ExecutePopiartTaskRequest = {
prompt: string;
selectedModel: SelectedModel;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
type?: number;
subType?: number;
referenceSubjectList?: unknown;

40
src/utils/__tests__/generationConfig.test.ts

@ -3,7 +3,9 @@ import {
buildGenerationConfigDraftFromModelSchema,
buildInitialGenerationNodeData,
buildPopiserverTaskPricePayload,
buildRuntimeExtraTaskParams,
buildRuntimeGenerationParameters,
buildRuntimeModelParameters,
buildSubmittableGenerationConfig,
readVideoGenerationConfig,
updateModelParameterValue,
@ -83,6 +85,44 @@ describe("generationConfig", () => {
});
});
it("merges extra task params into runtime generation parameters", () => {
const config = {
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
batchSize: 1,
referenceSubjectList: [],
parameters: {
width: 1024,
height: 768,
type: 1,
},
extraTaskParams: {
horizontal_angle: 15,
vertical_angle: 30,
zoom: 5,
subType: 113,
},
};
expect(buildRuntimeModelParameters(config)).toEqual({
width: 1024,
height: 768,
});
expect(buildRuntimeExtraTaskParams(config)).toEqual({
horizontal_angle: 15,
vertical_angle: 30,
zoom: 5,
});
expect(buildRuntimeGenerationParameters(config)).toEqual({
width: 1024,
height: 768,
horizontal_angle: 15,
vertical_angle: 30,
zoom: 5,
});
});
it("builds model switch drafts from original values or schema defaults", () => {
const draft = buildGenerationConfigDraftFromModelSchema(
{

17
src/utils/generationConfig.ts

@ -283,6 +283,10 @@ export function readVideoGenerationConfig(
defaultModel: SelectedModel
): GenerationNodeConfig {
const selectedModel = data.selectedModel ?? defaultModel;
const parameters = filterTaskRoutingParameters(data.parameters);
if (!hasOwnParameterValue(parameters, "duration") && data.durationSeconds !== undefined) {
parameters.duration = data.durationSeconds;
}
return {
prompt: data.inputPrompt ?? "",
selectedModel,
@ -291,7 +295,7 @@ export function readVideoGenerationConfig(
subType: data.subType,
audioVoiceId: undefined,
referenceSubjectList: data.referenceSubjectList ?? [],
parameters: filterTaskRoutingParameters(data.parameters),
parameters,
extraTaskParams: readExtraTaskParamsFromNode(data),
};
}
@ -377,11 +381,20 @@ export function buildInitialGenerationNodeData(
export function buildRuntimeGenerationParameters(config: GenerationNodeConfig): Record<string, unknown> {
const referenceSubjectList = config.referenceSubjectList ?? [];
return {
...filterSubmittableParameterValues(config.parameters),
...buildRuntimeModelParameters(config),
...buildRuntimeExtraTaskParams(config),
...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}),
};
}
export function buildRuntimeModelParameters(config: GenerationNodeConfig): Record<string, unknown> {
return filterSubmittableParameterValues(config.parameters);
}
export function buildRuntimeExtraTaskParams(config: GenerationNodeConfig): Record<string, unknown> {
return filterSubmittableParameterValues(config.extraTaskParams);
}
function filterSubmittableParameterValues(
parameters: Record<string, unknown> | undefined
): Record<string, unknown> {

4
src/utils/popiserverTaskPayload.ts

@ -195,6 +195,7 @@ export function buildPopiTaskBody(
modelDetail: PopiModelDetailForTaskPayload | null
): Record<string, unknown> {
const rawParameters = removeLegacyAudioParameters(input.parameters || {});
const rawExtraTaskParams = removeLegacyAudioParameters(input.extraTaskParams || {});
const aspectRatio = resolveAspectRatio(rawParameters);
const { referenceSubjectList: _referenceSubjectList, ...parameters } = rawParameters;
const selectedModel = {
@ -248,6 +249,8 @@ export function buildPopiTaskBody(
...(asNumber(input.batchSize) !== null || asNumber(parameters.batchSize) !== null
? { batchSize: asNumber(input.batchSize) ?? asNumber(parameters.batchSize) }
: {}),
referenceSubjectList,
extraTaskParams: rawExtraTaskParams,
};
if (type === 3) {
@ -265,6 +268,5 @@ export function buildPopiTaskBody(
...(images.length > 0 ? { images: [...new Set(images)] } : {}),
...(videos.length > 0 ? { videos: [...new Set(videos)] } : {}),
...(voices.length > 0 ? { voices: [...new Set(voices)] } : {}),
...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}),
};
}

Loading…
Cancel
Save