Browse Source

修复部分积分展示问题

TEST-s
weige 2 months ago
parent
commit
9b9331080e
  1. 74
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  2. 44
      src/app/api/generate/providers/newapiwg.ts
  3. 6
      src/app/api/generate/route.ts
  4. 8
      src/app/api/images/upload/route.ts
  5. 21
      src/app/api/points/estimate/__tests__/route.test.ts
  6. 28
      src/app/api/points/estimate/route.ts
  7. 17
      src/components/__tests__/GenerationComposer.test.tsx
  8. 8
      src/components/auth/LoginModal.tsx
  9. 67
      src/components/composer/GenerationComposer.tsx
  10. 29
      src/components/nodes/GenerateImageNode.tsx
  11. 57
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  12. 37
      src/store/execution/generateVideoExecutor.ts
  13. 11
      src/store/execution/nanoBananaExecutor.ts
  14. 85
      src/utils/temporaryImageUpload.ts

74
src/app/api/generate/providers/__tests__/newapiwg.test.ts

@ -746,7 +746,7 @@ describe("NewApiWG generation payloads", () => {
)).toBe(false); )).toBe(false);
}); });
it("resolves temporary local reference image URLs before OpenAI chat image requests", async () => { it("passes temporary local reference image URLs through OpenAI chat image requests", async () => {
const imageId = storeImage("data:image/png;base64,bG9jYWw="); const imageId = storeImage("data:image/png;base64,bG9jYWw=");
try { try {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
@ -766,7 +766,7 @@ describe("NewApiWG generation payloads", () => {
role: "user", role: "user",
content: [ content: [
{ type: "text", text: "喝咖啡" }, { type: "text", text: "喝咖啡" },
{ type: "image_url", image_url: { url: "data:image/png;base64,bG9jYWw=" } }, { type: "image_url", image_url: { url: `http://localhost:3000/api/images/${imageId}` } },
], ],
}]); }]);
} finally { } finally {
@ -774,9 +774,8 @@ describe("NewApiWG generation payloads", () => {
} }
}); });
it("returns a clear error when an OpenAI chat temporary reference image is missing", async () => { it("passes missing temporary local reference image URLs through OpenAI chat image requests", async () => {
const fetchMock = vi.mocked(fetch); const missingUrl = "http://localhost:3000/api/images/00000000-0000-0000-0000-000000000000";
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: { model: {
id: "gpt-image-2-all", id: "gpt-image-2-all",
@ -786,17 +785,20 @@ describe("NewApiWG generation payloads", () => {
capabilities: ["text-to-image", "image-to-image"], capabilities: ["text-to-image", "image-to-image"],
}, },
prompt: "喝咖啡", prompt: "喝咖啡",
images: ["http://localhost:3000/api/images/00000000-0000-0000-0000-000000000000"], images: [missingUrl],
})); }));
expect(result.success).toBe(false); expect(result.success).toBe(true);
expect(result.error).toContain("Local temporary reference image was not found"); expect(capturedBody!.messages).toEqual([{
expect(fetchMock.mock.calls.some(([url]) => role: "user",
String(url).includes("/chat/completions") content: [
)).toBe(false); { type: "text", text: "喝咖啡" },
{ type: "image_url", image_url: { url: missingUrl } },
],
}]);
}); });
it("lets composer aspect ratio override stale OpenAI-compatible size parameters", async () => { it("maps gpt-image-2-all resolution controls to supported chat completion size", async () => {
await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: { model: {
id: "gpt-image-2-all", id: "gpt-image-2-all",
@ -817,7 +819,53 @@ describe("NewApiWG generation payloads", () => {
})); }));
expect(capturedBody!.quality).toBe("auto"); expect(capturedBody!.quality).toBe("auto");
expect(capturedBody!.size).toBe("1024x1536"); expect(capturedBody!.size).toBe("2048x1152");
expect(capturedBody!.aspectRatio).toBeUndefined();
expect(capturedBody!.aspect_ratio).toBeUndefined();
expect(capturedBody!.resolution).toBeUndefined();
expect(capturedBody!.imageSize).toBeUndefined();
});
it("downgrades unsupported gpt-image-2-all 4K requests to 2K size", async () => {
await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "gpt-image-2-all",
name: "gpt-image-2-all",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
},
prompt: "draw a wide landscape",
parameters: {
resolution: "4K",
imageSize: "4K",
},
}));
expect(capturedBody!.size).toBe("2048x1152");
});
it("maps gpt-image-2-vip resolution controls to chat completion size", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "gpt-image-2-vip",
name: "gpt-image-2-vip",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
},
prompt: "draw a wide landscape",
parameters: {
resolution: "2K",
imageSize: "2K",
aspectRatio: "16:9",
aspect_ratio: "16:9",
},
}));
expect(result.success).toBe(true);
expect(capturedBody!.model).toBe("gpt-image-2-vip");
expect(capturedBody!.size).toBe("2048x1152");
expect(capturedBody!.aspectRatio).toBeUndefined(); expect(capturedBody!.aspectRatio).toBeUndefined();
expect(capturedBody!.aspect_ratio).toBeUndefined(); expect(capturedBody!.aspect_ratio).toBeUndefined();
expect(capturedBody!.resolution).toBeUndefined(); expect(capturedBody!.resolution).toBeUndefined();

44
src/app/api/generate/providers/newapiwg.ts

@ -958,10 +958,38 @@ function isImagenGenerationModel(modelId: string): boolean {
} }
function isOpenAIChatImageModel(modelId: string): boolean { function isOpenAIChatImageModel(modelId: string): boolean {
return /(^|[_\-/])sora[_\-/]?image$/i.test(modelId) || modelId === "gpt-image-2-all"; return /(^|[_\-/])sora[_\-/]?image$/i.test(modelId) ||
modelId === "gpt-image-2-all" ||
modelId === "gpt-image-2-vip";
} }
function resolveOpenAIChatImageSize(parameters: Record<string, unknown> | undefined): string | null { function resolveGptImage2Size(
modelId: string,
parameters: Record<string, unknown> | undefined
): string | null {
const resolution =
getStringParameter(parameters, "resolution") ??
getStringParameter(parameters, "imageSize") ??
getStringParameter(parameters, "image_size") ??
getStringParameter(parameters, "output_size") ??
getStringParameter(parameters, "size");
const normalizedResolution = resolution?.trim().toUpperCase();
if (normalizedResolution === "1K") return "1280x720";
if (normalizedResolution === "2K") return "2048x1152";
if (normalizedResolution === "4K" && modelId === "gpt-image-2-all") return "2048x1152";
if (normalizedResolution === "4K") return "3840x2160";
return getStringParameter(parameters, "size");
}
function resolveOpenAIChatImageSize(
modelId: string,
parameters: Record<string, unknown> | undefined
): string | null {
if (modelId === "gpt-image-2-all" || modelId === "gpt-image-2-vip") {
return resolveGptImage2Size(modelId, parameters) ?? "2048x1152";
}
const aspectRatio = const aspectRatio =
getStringParameter(parameters, "aspectRatio") ?? getStringParameter(parameters, "aspectRatio") ??
getStringParameter(parameters, "aspect_ratio"); getStringParameter(parameters, "aspect_ratio");
@ -976,10 +1004,11 @@ function resolveOpenAIChatImageSize(parameters: Record<string, unknown> | undefi
} }
function normalizeOpenAIChatImageParameters( function normalizeOpenAIChatImageParameters(
modelId: string,
parameters: Record<string, unknown> | undefined parameters: Record<string, unknown> | undefined
): Record<string, unknown> { ): Record<string, unknown> {
const normalized = { ...(parameters || {}) }; const normalized = { ...(parameters || {}) };
const size = resolveOpenAIChatImageSize(parameters); const size = resolveOpenAIChatImageSize(modelId, parameters);
if (size) normalized.size = size; if (size) normalized.size = size;
delete normalized.aspectRatio; delete normalized.aspectRatio;
delete normalized.aspect_ratio; delete normalized.aspect_ratio;
@ -1109,12 +1138,7 @@ function extractNewApiWGImage(value: unknown): string | null {
} }
function prepareOpenAIChatImageUrl(image: string): string { function prepareOpenAIChatImageUrl(image: string): string {
const temporaryDataUrl = resolveTemporaryImageUrlToDataUrl(image); return image;
if (temporaryDataUrl) return temporaryDataUrl;
if (!isLoopbackTemporaryImageUrl(image)) return image;
throw new Error("Local temporary reference image was not found. Please retry the generation.");
} }
async function generateImageViaNewApiWGChat( async function generateImageViaNewApiWGChat(
@ -1150,7 +1174,7 @@ async function generateImageViaNewApiWGChat(
messages: [{ role: "user", content }], messages: [{ role: "user", content }],
stream: false, stream: false,
...dynamicInputsWithoutPrompt(input), ...dynamicInputsWithoutPrompt(input),
...normalizeOpenAIChatImageParameters(input.parameters), ...normalizeOpenAIChatImageParameters(input.model.id, input.parameters),
}), }),
}, input.model.name, "NewApiWG chat image API error"); }, input.model.name, "NewApiWG chat image API error");

6
src/app/api/generate/route.ts

@ -207,7 +207,10 @@ export async function POST(request: NextRequest) {
dynamicInputs, dynamicInputs,
mediaType, mediaType,
} = body; } = body;
const requestImages = resolveTemporaryImageUrlsInArray(images); const provider: ProviderType = selectedModel?.provider || "gemini";
const requestImages = provider === "newapiwg"
? images || []
: resolveTemporaryImageUrlsInArray(images);
// Prompt is required unless: // Prompt is required unless:
// - Provided via dynamicInputs // - Provided via dynamicInputs
@ -228,7 +231,6 @@ export async function POST(request: NextRequest) {
} }
// Determine which provider to use // Determine which provider to use
const provider: ProviderType = selectedModel?.provider || "gemini";
const loginToken = getRequestToken(request); const loginToken = getRequestToken(request);
if (provider !== "newapiwg") { if (provider !== "newapiwg") {
requireLogin(request); requireLogin(request);

8
src/app/api/images/upload/route.ts

@ -5,6 +5,10 @@ function extensionFromMime(mimeType: string): string {
if (mimeType === "image/jpeg") return "jpg"; if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp"; if (mimeType === "image/webp") return "webp";
if (mimeType === "image/gif") return "gif"; if (mimeType === "image/gif") return "gif";
if (mimeType === "video/mp4") return "mp4";
if (mimeType === "video/webm") return "webm";
if (mimeType === "video/quicktime") return "mov";
if (mimeType.startsWith("video/")) return "mp4";
return "png"; return "png";
} }
@ -19,9 +23,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
); );
} }
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/") && !file.type.startsWith("video/")) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Only image uploads are supported" }, { success: false, error: "Only image and video uploads are supported" },
{ status: 400 } { status: 400 }
); );
} }

21
src/app/api/points/estimate/__tests__/route.test.ts

@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { POST } from "../route"; import { POST } from "../route";
const originalFetch = global.fetch; const originalFetch = global.fetch;
const originalBaseUrl = process.env.NEWAPIWG_BASE_URL; const originalBaseUrl = process.env.POPIART_API_BASE_URL;
function createPostRequest(body: unknown, headers?: Record<string, string>): NextRequest { function createPostRequest(body: unknown, headers?: Record<string, string>): NextRequest {
return new NextRequest("http://localhost/api/points/estimate", { return new NextRequest("http://localhost/api/points/estimate", {
@ -19,12 +19,12 @@ function createPostRequest(body: unknown, headers?: Record<string, string>): Nex
describe("/api/points/estimate route", () => { describe("/api/points/estimate route", () => {
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
process.env.NEWAPIWG_BASE_URL = originalBaseUrl; process.env.POPIART_API_BASE_URL = originalBaseUrl;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("returns 401 when user token is missing", async () => { it("returns 401 when user token is missing", async () => {
const response = await POST(createPostRequest({ model_name: "doubao-seedance-2-0-260128" })); const response = await POST(createPostRequest({ modelName: "doubao-seedance-2-0-260128" }));
await expect(response.json()).resolves.toEqual({ await expect(response.json()).resolves.toEqual({
success: false, success: false,
@ -34,11 +34,12 @@ describe("/api/points/estimate route", () => {
expect(response.status).toBe(401); expect(response.status).toBe(401);
}); });
it("forwards Seedance estimate requests to the NewApiWG points API with user auth", async () => { it("forwards Seedance estimate requests to the PopiArt points API with user auth", async () => {
process.env.POPIART_API_BASE_URL = "https://popi.example";
const payload = { const payload = {
model_name: "doubao-seedance-2-0-260128", modelName: "doubao-seedance-2-0-260128",
estimation_type: "seedance_video", estimationType: "seedance_video",
seedance_video: { seedanceVideo: {
resolution: "720p", resolution: "720p",
aspect_ratio: "16:9", aspect_ratio: "16:9",
input_video_duration_seconds: 0, input_video_duration_seconds: 0,
@ -48,8 +49,6 @@ describe("/api/points/estimate route", () => {
const upstreamBody = { const upstreamBody = {
success: true, success: true,
data: { data: {
model_name: "doubao-seedance-2-0-260128",
estimation_type: "seedance_video",
estimated_points: 123, estimated_points: 123,
}, },
}; };
@ -63,16 +62,16 @@ describe("/api/points/estimate route", () => {
const response = await POST(createPostRequest(payload, { const response = await POST(createPostRequest(payload, {
Authorization: "Bearer user-token", Authorization: "Bearer user-token",
"X-NewApiWG-Base-URL": "https://newapi.example/v1",
})); }));
await expect(response.json()).resolves.toEqual(upstreamBody); await expect(response.json()).resolves.toEqual(upstreamBody);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
"https://newapi.example/api/points/estimate", "https://popi.example/api_client/anime/ai/model/estimatePoints",
{ {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer user-token", Authorization: "Bearer user-token",
token: "user-token", token: "user-token",

28
src/app/api/points/estimate/route.ts

@ -1,31 +1,37 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { normalizeNewApiWGBaseUrl } from "@/app/api/generate/providers/newapiwg";
import { requireLogin } from "@/app/api/_auth"; import { requireLogin } from "@/app/api/_auth";
import { ApiError, apiErrorResponse } from "@/app/api/_errors"; import { ApiError, apiErrorResponse } from "@/app/api/_errors";
import { logGatewayRequest } from "@/app/api/_gatewayLogging"; import { logGatewayRequest } from "@/app/api/_gatewayLogging";
type PointsEstimateBody = { type PointsEstimateBody = {
model_name?: unknown; modelName?: unknown;
estimation_type?: unknown; estimationType?: unknown;
seedanceVideo?: unknown;
}; };
function getNewApiWGServerRootUrl(baseUrl?: string | null): string { const UPSTREAM_ESTIMATE_POINTS_PATH = "/api_client/anime/ai/model/estimatePoints";
return normalizeNewApiWGBaseUrl(baseUrl).replace(/\/v1(?:beta)?$/i, "");
function getUpstreamUrl(request: NextRequest): string {
const baseUrl =
process.env.POPIART_API_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
request.nextUrl.origin;
return baseUrl.replace(/\/+$/, "") + UPSTREAM_ESTIMATE_POINTS_PATH;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { token } = requireLogin(request); const { token } = requireLogin(request);
const body = (await request.json()) as PointsEstimateBody; const body = (await request.json()) as PointsEstimateBody;
const serverRootUrl = getNewApiWGServerRootUrl(request.headers.get("X-NewApiWG-Base-URL")); if (!body.modelName || !body.estimationType) {
if (!serverRootUrl) { throw ApiError.badRequest("modelName and estimationType are required");
throw ApiError.config("NEWAPIWG_BASE_URL is not configured");
} }
const url = `${serverRootUrl}/api/points/estimate`; const url = getUpstreamUrl(request);
const init: RequestInit = { const init: RequestInit = {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
token, token,
@ -34,8 +40,8 @@ export async function POST(request: NextRequest) {
cache: "no-store", cache: "no-store",
}; };
logGatewayRequest("Calling points estimate API", url, init, { logGatewayRequest("Calling points estimate API", url, init, {
modelName: typeof body.model_name === "string" ? body.model_name : undefined, modelName: typeof body.modelName === "string" ? body.modelName : undefined,
estimationType: typeof body.estimation_type === "string" ? body.estimation_type : undefined, estimationType: typeof body.estimationType === "string" ? body.estimationType : undefined,
}); });
const response = await fetch(url, init); const response = await fetch(url, init);

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

@ -470,6 +470,15 @@ describe("GenerationComposer", () => {
expect(screen.getByRole("button", { name: "2 points" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "2 points" })).toBeInTheDocument();
}); });
it("multiplies NewApiWG image point cost by image count", () => {
useWorkflowStore.setState({
nodes: [pricedImageNode("img-1", true, { imageCount: 4 })],
});
render(<GenerationComposer />);
expect(screen.getByRole("button", { name: "8 points" })).toBeInTheDocument();
});
it("uses NewApiWG pricing variant matching the selected resolution", () => { it("uses NewApiWG pricing variant matching the selected resolution", () => {
useWorkflowStore.setState({ useWorkflowStore.setState({
nodes: [ nodes: [
@ -552,8 +561,6 @@ describe("GenerationComposer", () => {
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true, success: true,
data: { data: {
model_name: "doubao-seedance-2-0-260128",
estimation_type: "seedance_video",
estimated_points: 123, estimated_points: 123,
}, },
}), { status: 200 }); }), { status: 200 });
@ -592,9 +599,9 @@ describe("GenerationComposer", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toEqual({ expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toEqual({
model_name: "doubao-seedance-2-0-260128", modelName: "doubao-seedance-2-0-260128",
estimation_type: "seedance_video", estimationType: "seedance_video",
seedance_video: { seedanceVideo: {
resolution: "720p", resolution: "720p",
aspect_ratio: "16:9", aspect_ratio: "16:9",
input_video_duration_seconds: 0, input_video_duration_seconds: 0,

8
src/components/auth/LoginModal.tsx

@ -421,9 +421,9 @@ export function LoginModal() {
> >
<div className="flex flex-col gap-4 pt-1"> <div className="flex flex-col gap-4 pt-1">
{reason === "unauthorized" ? ( {reason === "unauthorized" ? (
<Alert type="warning" showIcon message={t("auth.reloginRequired")} /> <Alert type="warning" showIcon title={t("auth.reloginRequired")} />
) : null} ) : null}
{error ? <Alert type="error" showIcon message={error} /> : null} {error ? <Alert type="error" showIcon title={error} /> : null}
<Segmented <Segmented
block block
@ -485,7 +485,7 @@ export function LoginModal() {
</> </>
) : ( ) : (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{wxQrError ? <Alert type="error" showIcon message={wxQrError} className="w-full" /> : null} {wxQrError ? <Alert type="error" showIcon title={wxQrError} className="w-full" /> : null}
{wxQrUrl ? ( {wxQrUrl ? (
<img <img
src={wxQrUrl} src={wxQrUrl}
@ -578,7 +578,7 @@ export function LoginModal() {
mask={{ closable: true }} mask={{ closable: true }}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{captchaError ? <Alert type="error" showIcon message={captchaError} /> : null} {captchaError ? <Alert type="error" showIcon title={captchaError} /> : null}
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
{captchaImage ? ( {captchaImage ? (
<img <img

67
src/components/composer/GenerationComposer.tsx

@ -89,9 +89,9 @@ type PointsEstimateResponse = {
}; };
type SeedanceVideoEstimatePayload = { type SeedanceVideoEstimatePayload = {
model_name: string; modelName: string;
estimation_type: "seedance_video"; estimationType: "seedance_video";
seedance_video: { seedanceVideo: {
resolution: string; resolution: string;
aspect_ratio: AspectRatio; aspect_ratio: AspectRatio;
input_video_duration_seconds: number; input_video_duration_seconds: number;
@ -109,6 +109,7 @@ interface ComposerAdapter<TData extends WorkflowNodeData> {
const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"]; const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"];
const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"];
const GPT_IMAGE_2_ALL_RESOLUTIONS: Resolution[] = ["1K", "2K"];
const VIDEO_DURATION_QUICK_OPTIONS = [4, 5, 8, 10, 15]; const VIDEO_DURATION_QUICK_OPTIONS = [4, 5, 8, 10, 15];
const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4]; const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4];
const MAX_REFERENCE_IMAGES = 4; const MAX_REFERENCE_IMAGES = 4;
@ -171,6 +172,17 @@ function getLegacyImageModel(model: SelectedModel): ModelType {
return "nano-banana-pro"; return "nano-banana-pro";
} }
function getImageResolutionOptions(model: SelectedModel): Resolution[] {
return model.provider === "newapiwg" && model.modelId === "gpt-image-2-all"
? GPT_IMAGE_2_ALL_RESOLUTIONS
: RESOLUTIONS;
}
function normalizeImageResolutionForModel(model: SelectedModel, resolution: string): string {
const options = getImageResolutionOptions(model);
return options.includes(resolution as Resolution) ? resolution : options.at(-1) ?? "2K";
}
function selectedModelFromProvider(model: ProviderModel): SelectedModel { function selectedModelFromProvider(model: ProviderModel): SelectedModel {
return { return {
provider: model.provider, provider: model.provider,
@ -306,9 +318,9 @@ function buildSeedanceVideoEstimatePayload(
inputVideoDurationSeconds: number inputVideoDurationSeconds: number
): SeedanceVideoEstimatePayload { ): SeedanceVideoEstimatePayload {
return { return {
model_name: draft.selectedModel.modelId, modelName: draft.selectedModel.modelId,
estimation_type: "seedance_video", estimationType: "seedance_video",
seedance_video: { seedanceVideo: {
resolution: draft.resolution || DEFAULT_VIDEO_RESOLUTION, resolution: draft.resolution || DEFAULT_VIDEO_RESOLUTION,
aspect_ratio: draft.aspectRatio || "16:9", aspect_ratio: draft.aspectRatio || "16:9",
input_video_duration_seconds: inputVideoDurationSeconds, input_video_duration_seconds: inputVideoDurationSeconds,
@ -838,11 +850,15 @@ export function GenerationComposer() {
const seedanceEstimateKey = seedanceEstimatePayload const seedanceEstimateKey = seedanceEstimatePayload
? JSON.stringify({ ? JSON.stringify({
baseUrl: newApiWGBaseUrl || "default",
...seedanceEstimatePayload, ...seedanceEstimatePayload,
}) })
: null; : null;
const imageResolutionOptions = useMemo(
() => getImageResolutionOptions(draft.selectedModel),
[draft.selectedModel.modelId, draft.selectedModel.provider]
);
useEffect(() => { useEffect(() => {
const selectedModel = draft.selectedModel; const selectedModel = draft.selectedModel;
if ( if (
@ -965,7 +981,6 @@ export function GenerationComposer() {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
if (newApiWGBaseUrl) headers["X-NewApiWG-Base-URL"] = newApiWGBaseUrl;
const response = await fetch("/api/points/estimate", { const response = await fetch("/api/points/estimate", {
method: "POST", method: "POST",
@ -989,7 +1004,7 @@ export function GenerationComposer() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [newApiWGBaseUrl, seedanceEstimateKey, seedanceEstimatePayload]); }, [seedanceEstimateKey, seedanceEstimatePayload]);
const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch"; const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch";
const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve"; const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve";
@ -1151,7 +1166,10 @@ export function GenerationComposer() {
: errorMessage : errorMessage
? t("composer.retryCurrentNode") ? t("composer.retryCurrentNode")
: nodeEditGenerateTitle; : nodeEditGenerateTitle;
const selectedPointAmount = seedanceEstimatedPointAmount ?? getSelectedPointAmount(draft, referenceImages.length); const selectedUnitPointAmount = seedanceEstimatedPointAmount ?? getSelectedPointAmount(draft, referenceImages.length);
const selectedPointAmount = selectedUnitPointAmount !== null && seedanceEstimatedPointAmount === null && activeCapability === "image"
? selectedUnitPointAmount * draft.imageCount
: selectedUnitPointAmount;
const modelPointCostLabel = selectedPointAmount !== null const modelPointCostLabel = selectedPointAmount !== null
? t("composer.pointCost", { count: formatPointAmount(selectedPointAmount) }) ? t("composer.pointCost", { count: formatPointAmount(selectedPointAmount) })
: null; : null;
@ -1211,6 +1229,20 @@ export function GenerationComposer() {
}); });
}, []); }, []);
useEffect(() => {
if (context.capability !== "image" && context.mode !== "empty-create") return;
const normalizedResolution = normalizeImageResolutionForModel(draft.selectedModel, draft.resolution);
if (normalizedResolution === draft.resolution) return;
markDraft({ resolution: normalizedResolution }, ["resolution"]);
}, [
context.capability,
context.mode,
draft.resolution,
draft.selectedModel.modelId,
draft.selectedModel.provider,
markDraft,
]);
const updateVideoQuickDraft = useCallback( const updateVideoQuickDraft = useCallback(
(patch: Partial<ComposerDraft>, fields: DraftField[]) => { (patch: Partial<ComposerDraft>, fields: DraftField[]) => {
const activeContext = contextRef.current; const activeContext = contextRef.current;
@ -1279,6 +1311,9 @@ export function GenerationComposer() {
(model: ProviderModel) => { (model: ProviderModel) => {
const nextModel = selectedModelFromProvider(model); const nextModel = selectedModelFromProvider(model);
const activeContext = contextRef.current; const activeContext = contextRef.current;
const nextResolution = modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_RESOLUTION
: normalizeImageResolutionForModel(nextModel, draftRef.current.resolution);
if (activeContext.mode === "node-edit") { if (activeContext.mode === "node-edit") {
const activeAdapter = getAdapter(activeContext.nodeType); const activeAdapter = getAdapter(activeContext.nodeType);
@ -1289,7 +1324,7 @@ export function GenerationComposer() {
{ {
...draftRef.current, ...draftRef.current,
selectedModel: nextModel, selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : draftRef.current.resolution, resolution: nextResolution,
durationSeconds: modelSupportsCapability(nextModel, "video") durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS ? DEFAULT_VIDEO_DURATION_SECONDS
: draftRef.current.durationSeconds, : draftRef.current.durationSeconds,
@ -1301,7 +1336,7 @@ export function GenerationComposer() {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
selectedModel: nextModel, selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution, resolution: nextResolution,
durationSeconds: modelSupportsCapability(nextModel, "video") durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS ? DEFAULT_VIDEO_DURATION_SECONDS
: current.durationSeconds, : current.durationSeconds,
@ -1316,7 +1351,9 @@ export function GenerationComposer() {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
selectedModel: nextModel, selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution, resolution: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_RESOLUTION
: normalizeImageResolutionForModel(nextModel, current.resolution),
durationSeconds: modelSupportsCapability(nextModel, "video") durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS ? DEFAULT_VIDEO_DURATION_SECONDS
: current.durationSeconds, : current.durationSeconds,
@ -2035,11 +2072,11 @@ export function GenerationComposer() {
</select> </select>
<select <select
value={draft.resolution} value={normalizeImageResolutionForModel(draft.selectedModel, draft.resolution)}
onChange={(event) => markDraft({ resolution: event.target.value as Resolution }, ["resolution"])} onChange={(event) => markDraft({ resolution: event.target.value as Resolution }, ["resolution"])}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60" className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
> >
{RESOLUTIONS.map((item) => ( {imageResolutionOptions.map((item) => (
<option key={item} value={item} className="bg-neutral-900"> <option key={item} value={item} className="bg-neutral-900">
{item} {item}
</option> </option>

29
src/components/nodes/GenerateImageNode.tsx

@ -54,6 +54,7 @@ const EXTENDED_ASPECT_RATIOS: AspectRatio[] = ["1:1", "1:4", "1:8", "2:3", "3:2"
// Resolutions per model (nano-banana-pro: 1K-4K, nano-banana-2: 512-4K) // Resolutions per model (nano-banana-pro: 1K-4K, nano-banana-2: 512-4K)
const RESOLUTIONS_PRO: Resolution[] = ["1K", "2K", "4K"]; const RESOLUTIONS_PRO: Resolution[] = ["1K", "2K", "4K"];
const RESOLUTIONS_NB2: Resolution[] = ["512", "1K", "2K", "4K"]; const RESOLUTIONS_NB2: Resolution[] = ["512", "1K", "2K", "4K"];
const RESOLUTIONS_GPT_IMAGE_2_ALL: Resolution[] = ["1K", "2K"];
const COMPOSER_PRIORITY_IMAGE_PARAMETERS = [ const COMPOSER_PRIORITY_IMAGE_PARAMETERS = [
"size", "size",
"image_size", "image_size",
@ -80,6 +81,22 @@ function isNewApiWGGeminiNativeImageModel(provider: ProviderType, modelId?: stri
return /nano[_\-/\s]?banana/i.test(modelId) || /gemini-.*image-preview/i.test(modelId); return /nano[_\-/\s]?banana/i.test(modelId) || /gemini-.*image-preview/i.test(modelId);
} }
function isNewApiWGChatImageModel(provider: ProviderType, modelId?: string): boolean {
if (provider !== "newapiwg" || !modelId) return false;
return modelId === "gpt-image-2-all" || modelId === "gpt-image-2-vip";
}
function getImageResolutionsForModel(modelId?: string | null): Resolution[] {
if (modelId === "gpt-image-2-all") return RESOLUTIONS_GPT_IMAGE_2_ALL;
if (isNanoBanana2Model(modelId)) return RESOLUTIONS_NB2;
return RESOLUTIONS_PRO;
}
function normalizeImageResolutionForModel(modelId: string | null | undefined, resolution: string | undefined): string {
const options = getImageResolutionsForModel(modelId);
return options.includes(resolution as Resolution) ? resolution! : options.at(-1) ?? "2K";
}
function isNanoBanana2Model(modelId?: string | null): boolean { function isNanoBanana2Model(modelId?: string | null): boolean {
if (!modelId) return false; if (!modelId) return false;
return /nano[_\-/\s]?banana[_\-/\s]?2/i.test(modelId) || /gemini-3\.1-flash-image-preview/i.test(modelId); return /nano[_\-/\s]?banana[_\-/\s]?2/i.test(modelId) || /gemini-3\.1-flash-image-preview/i.test(modelId);
@ -484,16 +501,20 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
const selectedModelId = nodeData.selectedModel?.modelId || nodeData.model; const selectedModelId = nodeData.selectedModel?.modelId || nodeData.model;
const usesFirstClassImageControls = const usesFirstClassImageControls =
isGeminiProvider || isNewApiWGGeminiNativeImageModel(currentProvider, selectedModelId); isGeminiProvider ||
isNewApiWGGeminiNativeImageModel(currentProvider, selectedModelId) ||
isNewApiWGChatImageModel(currentProvider, selectedModelId);
// Use selectedModel.modelId for Gemini/NewApiWG Gemini-native models, fallback to legacy model field. // Use selectedModel.modelId for Gemini/NewApiWG Gemini-native models, fallback to legacy model field.
const currentModelId = usesFirstClassImageControls ? selectedModelId : null; const currentModelId = usesFirstClassImageControls ? selectedModelId : null;
const isNanoBanana2 = isNanoBanana2Model(currentModelId); const isNanoBanana2 = isNanoBanana2Model(currentModelId);
const supportsResolution = const supportsResolution =
currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-pro" ||
currentModelId === "nano-banana-2" || currentModelId === "nano-banana-2" ||
isNewApiWGGeminiNativeImageModel(currentProvider, currentModelId ?? undefined); isNewApiWGGeminiNativeImageModel(currentProvider, currentModelId ?? undefined) ||
currentModelId === "gpt-image-2-all" ||
currentModelId === "gpt-image-2-vip";
const aspectRatios = isNanoBanana2 ? EXTENDED_ASPECT_RATIOS : BASE_ASPECT_RATIOS; const aspectRatios = isNanoBanana2 ? EXTENDED_ASPECT_RATIOS : BASE_ASPECT_RATIOS;
const resolutions = isNanoBanana2 ? RESOLUTIONS_NB2 : RESOLUTIONS_PRO; const resolutions = getImageResolutionsForModel(currentModelId);
const hasCarouselImages = (nodeData.imageHistory || []).length > 1; const hasCarouselImages = (nodeData.imageHistory || []).length > 1;
// Count visible first-class controls to match ModelParameters grid/max-width rules. // Count visible first-class controls to match ModelParameters grid/max-width rules.
@ -658,7 +679,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
<div key="resolution" className="flex items-center gap-2"> <div key="resolution" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">{t("node.resolution")}</label> <label className="text-[11px] text-neutral-400 shrink-0">{t("node.resolution")}</label>
<select <select
value={nodeData.resolution || "2K"} value={normalizeImageResolutionForModel(currentModelId, nodeData.resolution)}
onChange={handleResolutionChange} onChange={handleResolutionChange}
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 bg-[#1a1a1a] rounded-md focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white" className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 bg-[#1a1a1a] rounded-md focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
> >

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

@ -201,6 +201,63 @@ describe("executeGenerateVideo", () => {
expect(body.dynamicInputs).toEqual({ video_urls: "data:video/mp4;base64,input" }); expect(body.dynamicInputs).toEqual({ video_urls: "data:video/mp4;base64,input" });
}); });
it("uploads large NewApiWG video inputs before sending the generate request", async () => {
const largeVideo = `data:video/mp4;base64,${"a".repeat(400_000)}`;
const node = makeNode({
selectedModel: {
provider: "newapiwg",
modelId: "doubao-seedance-2-0-260128",
displayName: "Seedance",
capabilities: ["text-to-video", "image-to-video"],
},
});
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: [],
videos: [largeVideo],
audio: [],
text: null,
dynamicInputs: {},
easeCurve: null,
}),
});
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/images/upload") {
return {
ok: true,
json: () => Promise.resolve({
success: true,
id: "tmp-video-1",
url: "http://localhost:3000/api/images/tmp-video-1",
}),
};
}
if (url === "/api/generate") {
return {
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/out.mp4" }),
};
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
await executeGenerateVideo(ctx);
const generateCall = mockFetch.mock.calls.find(([url]) => url === "/api/generate");
const cleanupCall = mockFetch.mock.calls.find(
([url, init]) => url === "/api/images/upload" && init?.method === "DELETE"
);
const body = JSON.parse(generateCall?.[1].body);
expect(body.dynamicInputs).toEqual({
video: "http://localhost:3000/api/images/tmp-video-1",
videos: ["http://localhost:3000/api/images/tmp-video-1"],
});
expect(JSON.stringify(body)).not.toContain("base64");
expect(cleanupCall?.[1].body).toBe(JSON.stringify({ ids: ["tmp-video-1"] }));
});
it("maps first-class video fields into provider parameters", async () => { it("maps first-class video fields into provider parameters", async () => {
const node = makeNode({ const node = makeNode({
selectedModel: { provider: "kie", modelId: "bytedance/seedance-2/text-to-video", displayName: "Seedance" }, selectedModel: { provider: "kie", modelId: "bytedance/seedance-2/text-to-video", displayName: "Seedance" },

37
src/store/execution/generateVideoExecutor.ts

@ -25,6 +25,11 @@ import {
VIDEO_RESOLUTION_PARAMETER_NAMES, VIDEO_RESOLUTION_PARAMETER_NAMES,
VIDEO_SOUND_PARAMETER_NAMES, VIDEO_SOUND_PARAMETER_NAMES,
} from "@/utils/videoGenerationSettings"; } from "@/utils/videoGenerationSettings";
import {
cleanupTemporaryImages,
uploadTemporaryDynamicInputsForGeneration,
uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload";
export interface GenerateVideoOptions { export interface GenerateVideoOptions {
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ /** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */
@ -199,17 +204,29 @@ export async function executeGenerateVideo(
const runOnce = async (modelToUse: SelectedModel, parametersOverride?: Record<string, unknown>): Promise<void> => { const runOnce = async (modelToUse: SelectedModel, parametersOverride?: Record<string, unknown>): Promise<void> => {
const provider = modelToUse.provider; const provider = modelToUse.provider;
const headers = buildGenerateHeaders(provider, providerSettings); const headers = buildGenerateHeaders(provider, providerSettings);
let temporaryMediaIds: string[] = [];
const requestPayload = {
images,
prompt: text,
selectedModel: modelToUse,
parameters: buildVideoParameters(nodeData, modelToUse, parametersOverride),
dynamicInputs: requestDynamicInputs,
mediaType: "video" as const,
};
try { try {
const preparedImages = provider === "newapiwg"
? await uploadTemporaryImagesForGeneration(images)
: { images, cleanupIds: [] };
const preparedDynamicInputs = provider === "newapiwg"
? await uploadTemporaryDynamicInputsForGeneration(requestDynamicInputs)
: { dynamicInputs: requestDynamicInputs, cleanupIds: [] };
temporaryMediaIds = [
...preparedImages.cleanupIds,
...preparedDynamicInputs.cleanupIds,
];
const requestPayload = {
images: preparedImages.images,
prompt: text,
selectedModel: modelToUse,
parameters: buildVideoParameters(nodeData, modelToUse, parametersOverride),
dynamicInputs: preparedDynamicInputs.dynamicInputs,
mediaType: "video" as const,
};
const response = await fetch("/api/generate", { const response = await fetch("/api/generate", {
method: "POST", method: "POST",
headers, headers,
@ -381,6 +398,8 @@ export async function executeGenerateVideo(
error: errorMessage, error: errorMessage,
}); });
throw new Error(errorMessage); throw new Error(errorMessage);
} finally {
await cleanupTemporaryImages(temporaryMediaIds);
} }
}; };

11
src/store/execution/nanoBananaExecutor.ts

@ -29,6 +29,7 @@ import {
import { isHttpMediaUrl } from "@/utils/mediaResolver"; import { isHttpMediaUrl } from "@/utils/mediaResolver";
import { import {
cleanupTemporaryImages, cleanupTemporaryImages,
uploadTemporaryDynamicInputsForGeneration,
uploadTemporaryImagesForGeneration, uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload"; } from "@/utils/temporaryImageUpload";
@ -146,7 +147,13 @@ export async function executeNanoBanana(
const preparedImages = provider === "newapiwg" const preparedImages = provider === "newapiwg"
? await uploadTemporaryImagesForGeneration(images) ? await uploadTemporaryImagesForGeneration(images)
: { images, cleanupIds: [] }; : { images, cleanupIds: [] };
temporaryImageIds = preparedImages.cleanupIds; const preparedDynamicInputs = provider === "newapiwg"
? await uploadTemporaryDynamicInputsForGeneration(sanitizedDynamicInputs)
: { dynamicInputs: sanitizedDynamicInputs, cleanupIds: [] };
temporaryImageIds = [
...preparedImages.cleanupIds,
...preparedDynamicInputs.cleanupIds,
];
const requestPayload = { const requestPayload = {
images: preparedImages.images, images: preparedImages.images,
@ -158,7 +165,7 @@ export async function executeNanoBanana(
useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch, useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch,
selectedModel: modelToUse, selectedModel: modelToUse,
parameters: parametersOverride ?? nodeData.parameters, parameters: parametersOverride ?? nodeData.parameters,
dynamicInputs: sanitizedDynamicInputs, dynamicInputs: preparedDynamicInputs.dynamicInputs,
}; };
// Final guard: assert that prompt is a string before sending to API // Final guard: assert that prompt is a string before sending to API

85
src/utils/temporaryImageUpload.ts

@ -1,10 +1,20 @@
const TEMP_IMAGE_UPLOAD_THRESHOLD_BYTES = 256 * 1024; const TEMP_MEDIA_UPLOAD_THRESHOLD_BYTES = 256 * 1024;
type UploadedTemporaryImages = { type UploadedTemporaryImages = {
images: string[]; images: string[];
cleanupIds: string[]; cleanupIds: string[];
}; };
type UploadedTemporaryMedia = {
media: string[];
cleanupIds: string[];
};
type UploadedTemporaryDynamicInputs = {
dynamicInputs: Record<string, string | string[]>;
cleanupIds: string[];
};
function dataUrlMimeType(dataUrl: string): string | null { function dataUrlMimeType(dataUrl: string): string | null {
return dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? null; return dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? null;
} }
@ -29,21 +39,27 @@ function dataUrlToBlob(dataUrl: string): Blob {
return new Blob([bytes], { type: mimeType }); return new Blob([bytes], { type: mimeType });
} }
function shouldUploadTemporaryImage(image: string): boolean { function shouldUploadTemporaryMedia(media: string): boolean {
return image.startsWith("data:image/") && return (
approximateDataUrlBytes(image) > TEMP_IMAGE_UPLOAD_THRESHOLD_BYTES; (media.startsWith("data:image/") || media.startsWith("data:video/")) &&
approximateDataUrlBytes(media) > TEMP_MEDIA_UPLOAD_THRESHOLD_BYTES
);
} }
function extensionFromMime(mimeType: string | null): string { function extensionFromMime(mimeType: string | null): string {
if (mimeType === "image/jpeg") return "jpg"; if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp"; if (mimeType === "image/webp") return "webp";
if (mimeType === "image/gif") return "gif"; if (mimeType === "image/gif") return "gif";
if (mimeType === "video/mp4") return "mp4";
if (mimeType === "video/webm") return "webm";
if (mimeType === "video/quicktime") return "mov";
if (mimeType?.startsWith("video/")) return "mp4";
return "png"; return "png";
} }
async function uploadTemporaryImage(image: string): Promise<{ url: string; id: string }> { async function uploadTemporaryMedia(media: string): Promise<{ url: string; id: string }> {
const mimeType = dataUrlMimeType(image); const mimeType = dataUrlMimeType(media);
const blob = dataUrlToBlob(image); const blob = dataUrlToBlob(media);
const formData = new FormData(); const formData = new FormData();
formData.append("file", blob, `reference.${extensionFromMime(mimeType)}`); formData.append("file", blob, `reference.${extensionFromMime(mimeType)}`);
@ -68,20 +84,61 @@ async function uploadTemporaryImage(image: string): Promise<{ url: string; id: s
return { url: sameOriginUrl, id: result.id }; return { url: sameOriginUrl, id: result.id };
} }
export async function uploadTemporaryMediaForGeneration(
mediaItems: string[]
): Promise<UploadedTemporaryMedia> {
const cleanupIds: string[] = [];
const preparedMedia = await Promise.all(
mediaItems.map(async (media) => {
if (!shouldUploadTemporaryMedia(media)) return media;
const uploaded = await uploadTemporaryMedia(media);
cleanupIds.push(uploaded.id);
return uploaded.url;
})
);
return { media: preparedMedia, cleanupIds };
}
export async function uploadTemporaryImagesForGeneration( export async function uploadTemporaryImagesForGeneration(
images: string[] images: string[]
): Promise<UploadedTemporaryImages> { ): Promise<UploadedTemporaryImages> {
const prepared = await uploadTemporaryMediaForGeneration(images);
return { images: prepared.media, cleanupIds: prepared.cleanupIds };
}
export async function uploadTemporaryDynamicInputsForGeneration(
dynamicInputs: Record<string, string | string[]>
): Promise<UploadedTemporaryDynamicInputs> {
const cleanupIds: string[] = []; const cleanupIds: string[] = [];
const preparedImages = await Promise.all( const uploadCache = new Map<string, Promise<{ media: string; cleanupIds: string[] }>>();
images.map(async (image) => { const prepareMedia = async (media: string): Promise<string> => {
if (!shouldUploadTemporaryImage(image)) return image; if (!shouldUploadTemporaryMedia(media)) return media;
const uploaded = await uploadTemporaryImage(image); let upload = uploadCache.get(media);
cleanupIds.push(uploaded.id); if (!upload) {
return uploaded.url; upload = uploadTemporaryMedia(media).then((uploaded) => ({
media: uploaded.url,
cleanupIds: [uploaded.id],
}));
uploadCache.set(media, upload);
}
const prepared = await upload;
cleanupIds.push(...prepared.cleanupIds);
return prepared.media;
};
const entries = await Promise.all(
Object.entries(dynamicInputs).map(async ([key, value]) => {
if (typeof value === "string") {
return [key, await prepareMedia(value)] as const;
}
return [key, await Promise.all(value.map(prepareMedia))] as const;
}) })
); );
return { images: preparedImages, cleanupIds }; return { dynamicInputs: Object.fromEntries(entries), cleanupIds: [...new Set(cleanupIds)] };
} }
export async function cleanupTemporaryImages(ids: string[]): Promise<void> { export async function cleanupTemporaryImages(ids: string[]): Promise<void> {

Loading…
Cancel
Save