Browse Source

优化和去除Capabilities

feature/0710
yun 3 days ago
parent
commit
8f767dabdc
  1. 1184
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  2. 3
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  3. 443
      src/components/composer/GenerationComposer.tsx
  4. 242
      src/components/composer/composerDraftHelpers.ts
  5. 55
      src/components/composer/composerModelCapability.ts
  6. 70
      src/components/composer/composerReferenceEdges.ts
  7. 2
      src/lib/providers/types.ts
  8. 16
      src/store/execution/__tests__/llmGenerateExecutor.test.ts
  9. 2
      src/store/execution/popiMediaSupport.ts
  10. 4
      src/store/workflowStore.ts
  11. 4
      src/utils/__tests__/generationInputSupport.test.ts
  12. 82
      src/utils/generationInputSupport.ts
  13. 2
      src/utils/popiModelClassification.ts
  14. 8
      src/utils/popiserverTaskPayload.ts
  15. 2
      src/utils/videoGenerationSettings.ts

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

File diff suppressed because it is too large

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

@ -31,6 +31,7 @@ function makeInput(): GenerationInput {
aiModelId: 15,
aiModelCode: "viduq2-pro",
aiModelCodeAlias: "viduq2-pro",
isSupportImages: true,
},
},
prompt: "@角色1 和 @角色2 在一起吃火锅",
@ -426,6 +427,7 @@ describe("popiserver generation provider", () => {
aiModelId: 87,
aiModelCode: "gpt-image-2-all",
aiModelCodeAlias: "gpt-image-2-all",
isSupportImages: true,
},
};
input.prompt = "@图1 作为人物参考生成海报";
@ -676,6 +678,7 @@ describe("popiserver generation provider", () => {
aiModelId: 29,
aiModelCode: "seedance 2.0",
aiModelCodeAlias: "doubao-seedance-2-0-fast-260128",
isSupportImages: true,
isSupportAudios: true,
isSupportVideos: true,
},

443
src/components/composer/GenerationComposer.tsx

@ -15,7 +15,6 @@ import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { useModelStore, type PopiModelDetail } from "@/store/modelStore";
import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore";
import { getNodeOutputMediaType } from "@/utils/nodeHandles";
import { inferNodeSizeFromData, resolveNodeSizeFromAspectRatio } from "@/utils/nodeSizing";
import {
deriveComposerContextForNodeId,
@ -24,12 +23,8 @@ import {
} from "@/utils/composerNodeDescriptor";
import {
buildGenerationConfigDraftFromModelSchema,
buildInitialGenerationNodeData,
buildSubmittableGenerationConfig,
filterTaskRoutingParameters,
readAudioGenerationConfig,
readImageGenerationConfig,
readVideoGenerationConfig,
type GenerationNodeConfig,
type PopiserverTaskPayloadMedia,
} from "@/utils/generationConfig";
@ -41,7 +36,6 @@ import {
syncPromptReferenceMaterials,
type ComposerReferenceInput,
type ComposerReferenceMaterial,
type ComposerReferenceMaterialType,
} from "@/utils/composerReferenceSubjects";
import {
buildPopiserverPriceQuoteRequest,
@ -52,7 +46,38 @@ import {
buildComposerPricingContext,
buildFinalComposerPrompt,
} from "@/utils/composerPricingContext";
import { selectedModelCapabilities } from "@/utils/selectedModel";
import {
getConcreteGenerationNodeType,
inferCapabilityFromModel,
inferNodeTypeFromModel,
modelSupportsCapability,
preferenceTypeForCapability,
storedModelFitsCapability,
} from "@/components/composer/composerModelCapability";
import {
buildComposerConfigPersistPatch,
buildComposerGenerationConfig,
buildInitialDataForNode,
composerConfigEquals,
configFromDraft,
contextIdentity,
formatPointAmount,
getConnectedTextItems,
readAspectRatioFromComposerConfig,
readDraftFromContext,
referenceSubjectsToMaterials,
EMPTY_SELECTED_MODEL,
type ComposerConfig,
type ComposerDraft,
type ComposerGenerationConfig,
} from "@/components/composer/composerDraftHelpers";
import {
getConnectedEdgeIdsByType,
getConnectedEdgesByType,
getReferenceImageThumbnailUrl,
getReferenceVideoDurationSeconds,
uniqueReferenceVideoInputs,
} from "@/components/composer/composerReferenceEdges";
import {
ComposerPromptMaterials,
type ComposerConnectedTextCard,
@ -66,22 +91,16 @@ import type {
GenerateVideoNodeData,
ImageInputNodeData,
LLMGenerateNodeData,
LLMModelType,
NanoBananaNodeData,
NodeType,
SelectedModel,
SmartTextNodeData,
VideoStitchNodeData,
WorkflowEdge,
WorkflowNode,
WorkflowNodeData,
} from "@/types";
import type { ModelParameter } from "@/lib/providers/types";
import type { ConnectedInputs } from "@/store/utils/connectedInputs";
type ComposerDraft = GenerationNodeConfig;
export type ComposerGenerationConfig = Omit<GenerationNodeConfig, "prompt">;
type ComposerConfig = ComposerGenerationConfig;
export type { ComposerGenerationConfig };
type PricingInputsSnapshot = {
config: ComposerConfig;
@ -100,330 +119,11 @@ const PROMPT_LINE_HEIGHT_PX = 24;
const PROMPT_MIN_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 2;
const PROMPT_MAX_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 4;
const EMPTY_EXTRA_TASK_PARAMS: Record<string, unknown> = {};
const ASPECT_RATIO_PARAMETER_NAMES = ["ratio", "aspectRatio", "aspect_ratio", "imageRatio", "image_ratio"];
const EMPTY_SELECTED_MODEL: SelectedModel = {
provider: "popiserver",
modelId: "",
displayName: "",
capabilities: [],
};
const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = {
provider: "popiserver",
modelId: "",
displayName: "",
capabilities: ["text-to-text"],
};
function getComposerModelPopupContainer(): HTMLElement {
return document.body;
}
function finiteNumberFromUnknown(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function getConnectedTextItems(inputs: { text: string | null; textItems: string[] } | null | undefined): string[] {
if (!inputs) return [];
const items = inputs.textItems.length > 0 ? inputs.textItems : inputs.text ? [inputs.text] : [];
return items.map((item) => item.trim()).filter(Boolean);
}
function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean {
return Boolean(selectedModelCapabilities(model).some((capability) => capabilities.includes(capability)));
}
function modelSupportsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (capability === "image") return hasAnyCapability(model, ["text-to-image", "image-to-image"]);
if (capability === "video") return hasAnyCapability(model, ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"]);
if (capability === "3d") return hasAnyCapability(model, ["text-to-3d", "image-to-3d"]);
if (capability === "llm") return hasAnyCapability(model, ["text-to-text"]);
return hasAnyCapability(model, ["text-to-audio"]);
}
function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (selectedModelCapabilities(model).length === 0) return true;
return modelSupportsCapability(model, capability);
}
function inferCapabilityFromModel(model: SelectedModel): Exclude<ComposerCapability, "all"> | null {
if (modelSupportsCapability(model, "video")) return "video";
if (modelSupportsCapability(model, "3d")) return "3d";
if (modelSupportsCapability(model, "audio")) return "audio";
if (modelSupportsCapability(model, "image")) return "image";
if (modelSupportsCapability(model, "llm")) return "llm";
return null;
}
function inferNodeTypeFromModel(model: SelectedModel): NodeType | null {
const capability = inferCapabilityFromModel(model);
if (capability === "video") return "smartVideo";
if (capability === "3d") return "generate3d";
if (capability === "audio") return "smartAudio";
if (capability === "image") return "smartImage";
if (capability === "llm") return "smartText";
return null;
}
function preferenceTypeForCapability(capability: ComposerCapability): "image" | "video" | "audio" | null {
if (capability === "image") return "image";
if (capability === "video") return "video";
if (capability === "audio") return "audio";
return null;
}
function formatPointAmount(amount: number): string {
if (Number.isInteger(amount)) return String(amount);
return amount.toFixed(2).replace(/\.?0+$/, "");
}
function createEmptyDraft(): ComposerDraft {
return {
prompt: "",
selectedModel: EMPTY_SELECTED_MODEL,
inputImages: [],
batchSize: 1,
audioVoiceId: undefined,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},
};
}
function configFromDraft(draft: ComposerDraft): ComposerConfig {
const { prompt: _prompt, ...config } = draft;
return config;
}
function buildComposerGenerationConfig(config: ComposerConfig, prompt: string): GenerationNodeConfig {
return {
...config,
prompt,
};
}
function readAspectRatioFromComposerConfig(config: Pick<GenerationNodeConfig, "parameters">): string | undefined {
for (const name of ASPECT_RATIO_PARAMETER_NAMES) {
const value = config.parameters?.[name];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function composerConfigEquals(a: ComposerConfig, b: ComposerConfig): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function referenceSubjectsToMaterials(subjects: ComposerConfig["referenceSubjectList"]): ComposerReferenceMaterial[] {
return readReferenceSubjectList(subjects).map((subject, index) => ({
id: index + 1,
type: subject.type,
url: subject.url,
name: subject.name.startsWith("@") ? subject.name : `@${subject.name}`,
sourceNodeId: subject.id,
...(subject.thumbnailUrl ? { thumbnailUrl: subject.thumbnailUrl } : {}),
...(subject.duration ? { duration: subject.duration } : {}),
}));
}
function createPromptDraft(node: WorkflowNode): ComposerDraft {
const data = node.data as { prompt?: unknown };
return {
...createEmptyDraft(),
prompt: typeof data.prompt === "string" ? data.prompt : "",
selectedModel: DEFAULT_PROMPT_TEXT_MODEL,
};
}
function createSelectedModelFromLlmData(data: LLMGenerateNodeData): SelectedModel {
if (data.selectedModel?.provider === "popiserver") return data.selectedModel;
const model = data.model || "";
return {
provider: "popiserver",
modelId: model,
displayName: model,
capabilities: ["text-to-text"],
};
}
function readDraftFromContext(context: ComposerContext): ComposerDraft {
if (context.mode === "empty-create") return createEmptyDraft();
if (context.mode === "unsupported-node" && context.node?.type === "prompt") return createPromptDraft(context.node);
if (!context.node) return createEmptyDraft();
if (context.node.type === "smartImage" || context.node.type === "nanoBanana") {
return readImageGenerationConfig(context.node.data as NanoBananaNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "smartVideo" || context.node.type === "generateVideo") {
return readVideoGenerationConfig(context.node.data as GenerateVideoNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "smartAudio" || context.node.type === "generateAudio") {
return readAudioGenerationConfig(context.node.data as GenerateAudioNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "llmGenerate" || context.node.type === "smartText") {
const data = context.node.data as LLMGenerateNodeData;
return {
...createEmptyDraft(),
prompt: data.inputPrompt ?? "",
selectedModel: createSelectedModelFromLlmData(data),
parameters: {},
};
}
return createEmptyDraft();
}
function contextIdentity(context: ComposerContext): string {
return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}:${context.selectedCount}`;
}
function getConcreteGenerationNodeType(nodeType: NodeType | null): NodeType | null {
if (nodeType === "smartImage") return "nanoBanana";
if (nodeType === "smartVideo") return "generateVideo";
if (nodeType === "smartAudio") return "generateAudio";
return null;
}
function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial<WorkflowNodeData> {
if (
nodeType === "smartImage" ||
nodeType === "smartVideo" ||
nodeType === "smartAudio" ||
nodeType === "nanoBanana" ||
nodeType === "generateVideo" ||
nodeType === "generate3d" ||
nodeType === "generateAudio"
) {
return buildInitialGenerationNodeData(nodeType, draft);
}
if (nodeType === "llmGenerate" || nodeType === "smartText") {
return {
...(nodeType === "smartText" ? { manualLocked: false, manualText: "" } : {}),
inputPrompt: draft.prompt,
inputImages: [],
inputVideos: [],
outputText: null,
provider: "popiserver",
model: draft.selectedModel.modelId as LLMModelType,
selectedModel: {
...draft.selectedModel,
capabilities: draft.selectedModel.capabilities ?? ["text-to-text"],
},
status: "idle",
error: null,
} as Partial<LLMGenerateNodeData | SmartTextNodeData>;
}
return {};
}
function buildComposerPersistParameters(node: WorkflowNode, config: ComposerConfig): ComposerConfig["parameters"] {
void node;
return filterTaskRoutingParameters(config.parameters);
}
function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationNodeConfig): Partial<WorkflowNodeData> {
const nodeType = node.type;
const parameters = buildComposerPersistParameters(node, config);
if (nodeType === "smartImage" || nodeType === "nanoBanana") {
const aspectRatio = readAspectRatioFromComposerConfig(config);
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
batchSize: config.batchSize,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
...(aspectRatio ? { aspectRatio } : {}),
} as Partial<NanoBananaNodeData>;
}
if (nodeType === "smartVideo" || nodeType === "generateVideo") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
subType: config.subType,
} as Partial<GenerateVideoNodeData>;
}
if (nodeType === "smartAudio" || nodeType === "generateAudio") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
audioVoiceId: config.audioVoiceId,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
} as Partial<GenerateAudioNodeData>;
}
if (nodeType === "llmGenerate" || nodeType === "smartText") {
return {
inputPrompt: config.prompt,
selectedModel: config.selectedModel,
model: config.selectedModel.modelId as LLMModelType,
} as Partial<LLMGenerateNodeData | SmartTextNodeData>;
}
return {};
}
function getConnectedEdgesByType(
targetNodeId: string,
nodes: WorkflowNode[],
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType
): Array<{ id: string; sourceNodeId: string }> {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const sourceNode = nodes.find((node) => node.id === edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => ({ id: edge.id, sourceNodeId: edge.source }));
}
function getConnectedEdgeIdsByType(
targetNodeId: string,
nodes: WorkflowNode[],
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType | "text"
): string[] {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const sourceNode = nodes.find((node) => node.id === edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => edge.id);
}
function getReferenceImageThumbnailUrl(sourceNode: WorkflowNode | undefined): string | undefined {
const previewImg = (sourceNode?.data as { previewImg?: unknown } | undefined)?.previewImg;
return typeof previewImg === "string" && previewImg.length > 0 ? previewImg : undefined;
}
function getReferenceVideoDurationSeconds(videos: ComposerReferenceInput[]): number {
const seenUrls = new Set<string>();
const totalDuration = videos.reduce((sum, video) => {
if (seenUrls.has(video.url)) return sum;
seenUrls.add(video.url);
const duration = finiteNumberFromUnknown(video.duration);
return duration !== null && duration > 0 ? sum + duration : sum;
}, 0);
return Math.ceil(totalDuration);
}
function uniqueReferenceVideoInputs(videos: ComposerReferenceInput[]): ComposerReferenceInput[] {
const seenUrls = new Set<string>();
return videos.filter((video) => {
if (seenUrls.has(video.url)) return false;
seenUrls.add(video.url);
return true;
});
}
function IconButton({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
return (
<button
@ -459,7 +159,6 @@ interface ComposerEditorProps {
initialDraft: ComposerDraft;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
dimmedNodeIds: Set<string>;
isCurrentNodeRunning: boolean;
embedded: boolean;
isHidden: boolean;
@ -476,7 +175,6 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
const { t } = useI18n();
const nodes = useWorkflowStore((state) => state.nodes);
const edges = useWorkflowStore((state) => state.edges);
const dimmedNodeIds = useWorkflowStore((state) => state.dimmedNodeIds);
const runningNodeIds = useWorkflowStore((state) => state.runningNodeIds);
const addNode = useWorkflowStore((state) => state.addNode);
const convertNodeType = useWorkflowStore((state) => state.convertNodeType);
@ -678,7 +376,6 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
initialDraft={initialConfig}
nodes={nodes}
edges={edges}
dimmedNodeIds={dimmedNodeIds}
isCurrentNodeRunning={isCurrentNodeRunning}
embedded={embedded}
isHidden={isHidden}
@ -699,7 +396,6 @@ function ComposerEditor({
initialDraft,
nodes,
edges,
dimmedNodeIds,
isCurrentNodeRunning,
embedded,
isHidden,
@ -711,7 +407,6 @@ function ComposerEditor({
onPersistConfig,
onHiddenPersisted,
}: ComposerEditorProps) {
void dimmedNodeIds;
void embedded;
const { t } = useI18n();
const promptInputRef = useRef<HTMLDivElement>(null);
@ -726,6 +421,7 @@ function ComposerEditor({
isLoading: false,
});
const priceRequestIdRef = useRef(0);
const priceAbortRef = useRef<AbortController | null>(null);
const suppressAutoPriceRefreshRef = useRef(false);
const modelChangeCompletionTimerRef = useRef<number | null>(null);
const priceRefreshTimerRef = useRef<number | null>(null);
@ -744,6 +440,12 @@ function ComposerEditor({
const reorderNodeReferenceSubjectList = useWorkflowStore((state) => state.reorderNodeReferenceSubjectList);
const updateNodeExtraTaskParams = useWorkflowStore((state) => state.updateNodeExtraTaskParams);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const nodeById = useMemo(() => {
const map = new Map<string, WorkflowNode>();
for (const node of nodes) map.set(node.id, node);
return map;
}, [nodes]);
const buildGenerationConfigForContext = useCallback((
targetContext: ComposerContext,
nextConfig: ComposerConfig = config,
@ -767,18 +469,20 @@ function ComposerEditor({
const connectedInputs = useMemo(() => {
if ((context.mode !== "node-edit" && context.mode !== "process-node") || !context.node) return null;
return useWorkflowStore.getState().getConnectedInputs(context.node.id);
}, [context, nodes, edges]);
return getConnectedInputs(context.node.id);
// getConnectedInputs reads the latest store state internally; nodes/edges gate recomputation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, nodes, edges, getConnectedInputs]);
const connectedTextItems = useMemo(() => getConnectedTextItems(connectedInputs), [connectedInputs]);
const connectedTextCards = useMemo<ComposerConnectedTextCard[]>(() => {
if (!context.node) return connectedTextItems.map((text) => ({ text }));
const textEdgeIds = getConnectedEdgeIdsByType(context.node.id, nodes, edges, "text");
const textEdgeIds = getConnectedEdgeIdsByType(context.node.id, nodeById, edges, "text");
return connectedTextItems.map((text, index) => ({
text,
...(textEdgeIds[index] ? { edgeId: textEdgeIds[index] } : {}),
}));
}, [connectedTextItems, context.node, edges, nodes]);
}, [connectedTextItems, context.node, edges, nodeById]);
const currentModelId = config.selectedModel.modelId;
const activeModelDetail = useModelStore((state) => currentModelId ? state.detailsById[currentModelId] : undefined);
@ -836,7 +540,7 @@ function ComposerEditor({
const videoItems = context.mode === "node-edit" ? connectedInputs?.videoItems ?? [] : [];
const videoEdges =
context.mode === "node-edit" && context.node
? getConnectedEdgesByType(context.node.id, nodes, edges, "video")
? getConnectedEdgesByType(context.node.id, nodeById, edges, "video")
: [];
return uniqueReferenceVideoInputs(videoItems.map((video, index) => ({
url: video.url,
@ -844,18 +548,18 @@ function ComposerEditor({
...(videoEdges[index]?.id ? { edgeId: videoEdges[index].id } : {}),
...(videoEdges[index]?.sourceNodeId ? { sourceNodeId: videoEdges[index].sourceNodeId } : {}),
})));
}, [connectedInputs?.videoItems, context.mode, context.node, edges, nodes]);
}, [connectedInputs?.videoItems, context.mode, context.node, edges, nodeById]);
const referenceImageInputs = useMemo<ComposerReferenceInput[]>(() => {
const imageEdges =
context.mode === "node-edit" && context.node && connectedReferenceImages.length > 0
? getConnectedEdgesByType(context.node.id, nodes, edges, "image")
? getConnectedEdgesByType(context.node.id, nodeById, edges, "image")
: [];
return referenceImages.map((url, index) => {
const edge = imageEdges[index];
const sourceNodeId = edge?.sourceNodeId;
const thumbnailUrl = sourceNodeId
? getReferenceImageThumbnailUrl(nodes.find((node) => node.id === sourceNodeId))
? getReferenceImageThumbnailUrl(nodeById.get(sourceNodeId))
: undefined;
return {
url,
@ -864,19 +568,19 @@ function ComposerEditor({
...(sourceNodeId ? { sourceNodeId } : {}),
};
});
}, [connectedReferenceImages.length, context.mode, context.node, edges, nodes, referenceImages]);
}, [connectedReferenceImages.length, context.mode, context.node, edges, nodeById, referenceImages]);
const referenceAudioInputs = useMemo<ComposerReferenceInput[]>(() => {
const audioEdges =
context.mode === "node-edit" && context.node
? getConnectedEdgesByType(context.node.id, nodes, edges, "audio")
? getConnectedEdgesByType(context.node.id, nodeById, edges, "audio")
: [];
return connectedReferenceAudio.map((url, index) => ({
url,
...(audioEdges[index]?.id ? { edgeId: audioEdges[index].id } : {}),
...(audioEdges[index]?.sourceNodeId ? { sourceNodeId: audioEdges[index].sourceNodeId } : {}),
}));
}, [connectedReferenceAudio, context.mode, context.node, edges, nodes]);
}, [connectedReferenceAudio, context.mode, context.node, edges, nodeById]);
const baseReferenceMaterials = useMemo(() => buildComposerReferenceMaterials(
referenceImageInputs,
@ -923,17 +627,21 @@ function ComposerEditor({
voices: referenceAudioInputs.map((input) => input.url),
}), [estimateReferenceVideoInputs, referenceAudioInputs, referenceImages]);
pricingInputsRef.current = {
config,
prompt,
activeModelDetail,
modelSchema,
extensionFields,
connectedInputs,
connectedVideoDurationSeconds,
popiserverPricingMedia,
isHidden,
};
// Keep the pricing snapshot ref current without writing to a ref during render.
// All readers (debounced timers, model-change completion) run after commit.
useEffect(() => {
pricingInputsRef.current = {
config,
prompt,
activeModelDetail,
modelSchema,
extensionFields,
connectedInputs,
connectedVideoDurationSeconds,
popiserverPricingMedia,
isHidden,
};
});
const refreshPrice = useCallback(async (
nextConfig: ComposerConfig,
@ -944,6 +652,10 @@ function ComposerEditor({
const requestId = priceRequestIdRef.current + 1;
priceRequestIdRef.current = requestId;
// Cancel any in-flight quote request; a newer one supersedes it.
priceAbortRef.current?.abort();
priceAbortRef.current = null;
if (!nextConfig.selectedModel.modelId) {
setPriceState({ amount: null, isLoading: false });
return;
@ -971,7 +683,6 @@ function ComposerEditor({
media: inputs.popiserverPricingMedia,
inputVideoDurationSeconds: inputs.connectedVideoDurationSeconds,
};
console.info("[points quote] build input params", pricingContextInput);
const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext(pricingContextInput));
if (!quoteRequest) {
@ -979,20 +690,22 @@ function ComposerEditor({
return;
}
console.info("[points quote] request params", quoteRequest);
try {
const controller = new AbortController();
priceAbortRef.current = controller;
const response = await fetch("/api/points/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(quoteRequest),
signal: controller.signal,
});
const data = response.ok ? await response.json() as PopiserverPriceQuoteResponse : null;
const amount = data ? getQuotedPointAmount(data) : null;
if (priceRequestIdRef.current === requestId) {
setPriceState({ amount, isLoading: false });
}
} catch {
} catch (error) {
if ((error as { name?: string })?.name === "AbortError") return;
if (priceRequestIdRef.current === requestId) setPriceState({ amount: null, isLoading: false });
}
}, [
@ -1223,6 +936,8 @@ function ComposerEditor({
if (priceRefreshTimerRef.current !== null) {
window.clearTimeout(priceRefreshTimerRef.current);
}
priceAbortRef.current?.abort();
priceAbortRef.current = null;
}, []);
const processBody = isProcessNode ? (

242
src/components/composer/composerDraftHelpers.ts

@ -0,0 +1,242 @@
import {
buildInitialGenerationNodeData,
filterTaskRoutingParameters,
readAudioGenerationConfig,
readImageGenerationConfig,
readVideoGenerationConfig,
type GenerationNodeConfig,
} from "@/utils/generationConfig";
import {
readReferenceSubjectList,
type ComposerReferenceMaterial,
} from "@/utils/composerReferenceSubjects";
import type { ComposerContext } from "@/utils/composerNodeDescriptor";
import type {
GenerateAudioNodeData,
GenerateVideoNodeData,
LLMGenerateNodeData,
LLMModelType,
NanoBananaNodeData,
NodeType,
SelectedModel,
SmartTextNodeData,
WorkflowNode,
WorkflowNodeData,
} from "@/types";
export type ComposerDraft = GenerationNodeConfig;
export type ComposerGenerationConfig = Omit<GenerationNodeConfig, "prompt">;
export type ComposerConfig = ComposerGenerationConfig;
export const ASPECT_RATIO_PARAMETER_NAMES = ["ratio", "aspectRatio", "aspect_ratio", "imageRatio", "image_ratio"];
export const EMPTY_SELECTED_MODEL: SelectedModel = {
provider: "popiserver",
modelId: "",
displayName: "",
capabilities: [],
};
export const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = {
provider: "popiserver",
modelId: "",
displayName: "",
capabilities: ["text-to-text"],
};
export function getConnectedTextItems(inputs: { text: string | null; textItems: string[] } | null | undefined): string[] {
if (!inputs) return [];
const items = inputs.textItems.length > 0 ? inputs.textItems : inputs.text ? [inputs.text] : [];
return items.map((item) => item.trim()).filter(Boolean);
}
export function formatPointAmount(amount: number): string {
if (Number.isInteger(amount)) return String(amount);
return amount.toFixed(2).replace(/\.?0+$/, "");
}
export function createEmptyDraft(): ComposerDraft {
return {
prompt: "",
selectedModel: EMPTY_SELECTED_MODEL,
inputImages: [],
batchSize: 1,
audioVoiceId: undefined,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},
};
}
export function configFromDraft(draft: ComposerDraft): ComposerConfig {
const { prompt: _prompt, ...config } = draft;
return config;
}
export function buildComposerGenerationConfig(config: ComposerConfig, prompt: string): GenerationNodeConfig {
return {
...config,
prompt,
};
}
export function readAspectRatioFromComposerConfig(config: Pick<GenerationNodeConfig, "parameters">): string | undefined {
for (const name of ASPECT_RATIO_PARAMETER_NAMES) {
const value = config.parameters?.[name];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function stableSerialize(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableSerialize((value as Record<string, unknown>)[key])}`);
return `{${entries.join(",")}}`;
}
export function composerConfigEquals(a: ComposerConfig, b: ComposerConfig): boolean {
return stableSerialize(a) === stableSerialize(b);
}
export function referenceSubjectsToMaterials(subjects: ComposerConfig["referenceSubjectList"]): ComposerReferenceMaterial[] {
return readReferenceSubjectList(subjects).map((subject, index) => ({
id: index + 1,
type: subject.type,
url: subject.url,
name: subject.name.startsWith("@") ? subject.name : `@${subject.name}`,
sourceNodeId: subject.id,
...(subject.thumbnailUrl ? { thumbnailUrl: subject.thumbnailUrl } : {}),
...(subject.duration ? { duration: subject.duration } : {}),
}));
}
export function createPromptDraft(node: WorkflowNode): ComposerDraft {
const data = node.data as { prompt?: unknown };
return {
...createEmptyDraft(),
prompt: typeof data.prompt === "string" ? data.prompt : "",
selectedModel: DEFAULT_PROMPT_TEXT_MODEL,
};
}
export function createSelectedModelFromLlmData(data: LLMGenerateNodeData): SelectedModel {
if (data.selectedModel?.provider === "popiserver") return data.selectedModel;
const model = data.model || "";
return {
provider: "popiserver",
modelId: model,
displayName: model,
capabilities: ["text-to-text"],
};
}
export function readDraftFromContext(context: ComposerContext): ComposerDraft {
if (context.mode === "empty-create") return createEmptyDraft();
if (context.mode === "unsupported-node" && context.node?.type === "prompt") return createPromptDraft(context.node);
if (!context.node) return createEmptyDraft();
if (context.node.type === "smartImage" || context.node.type === "nanoBanana") {
return readImageGenerationConfig(context.node.data as NanoBananaNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "smartVideo" || context.node.type === "generateVideo") {
return readVideoGenerationConfig(context.node.data as GenerateVideoNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "smartAudio" || context.node.type === "generateAudio") {
return readAudioGenerationConfig(context.node.data as GenerateAudioNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "llmGenerate" || context.node.type === "smartText") {
const data = context.node.data as LLMGenerateNodeData;
return {
...createEmptyDraft(),
prompt: data.inputPrompt ?? "",
selectedModel: createSelectedModelFromLlmData(data),
parameters: {},
};
}
return createEmptyDraft();
}
export function contextIdentity(context: ComposerContext): string {
return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}:${context.selectedCount}`;
}
export function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial<WorkflowNodeData> {
if (
nodeType === "smartImage" ||
nodeType === "smartVideo" ||
nodeType === "smartAudio" ||
nodeType === "nanoBanana" ||
nodeType === "generateVideo" ||
nodeType === "generate3d" ||
nodeType === "generateAudio"
) {
return buildInitialGenerationNodeData(nodeType, draft);
}
if (nodeType === "llmGenerate" || nodeType === "smartText") {
return {
...(nodeType === "smartText" ? { manualLocked: false, manualText: "" } : {}),
inputPrompt: draft.prompt,
inputImages: [],
inputVideos: [],
outputText: null,
provider: "popiserver",
model: draft.selectedModel.modelId as LLMModelType,
selectedModel: {
...draft.selectedModel,
capabilities: draft.selectedModel.capabilities ?? ["text-to-text"],
},
status: "idle",
error: null,
} as Partial<LLMGenerateNodeData | SmartTextNodeData>;
}
return {};
}
export function buildComposerPersistParameters(node: WorkflowNode, config: ComposerConfig): ComposerConfig["parameters"] {
void node;
return filterTaskRoutingParameters(config.parameters);
}
export function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationNodeConfig): Partial<WorkflowNodeData> {
const nodeType = node.type;
const parameters = buildComposerPersistParameters(node, config);
if (nodeType === "smartImage" || nodeType === "nanoBanana") {
const aspectRatio = readAspectRatioFromComposerConfig(config);
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
batchSize: config.batchSize,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
...(aspectRatio ? { aspectRatio } : {}),
} as Partial<NanoBananaNodeData>;
}
if (nodeType === "smartVideo" || nodeType === "generateVideo") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
subType: config.subType,
} as Partial<GenerateVideoNodeData>;
}
if (nodeType === "smartAudio" || nodeType === "generateAudio") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
audioVoiceId: config.audioVoiceId,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
} as Partial<GenerateAudioNodeData>;
}
if (nodeType === "llmGenerate" || nodeType === "smartText") {
return {
inputPrompt: config.prompt,
selectedModel: config.selectedModel,
model: config.selectedModel.modelId as LLMModelType,
} as Partial<LLMGenerateNodeData | SmartTextNodeData>;
}
return {};
}

55
src/components/composer/composerModelCapability.ts

@ -0,0 +1,55 @@
import type { NodeType, SelectedModel } from "@/types";
import type { ComposerCapability } from "@/utils/composerNodeDescriptor";
import { selectedModelCapabilities } from "@/utils/selectedModel";
export function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean {
return Boolean(selectedModelCapabilities(model).some((capability) => capabilities.includes(capability)));
}
export function modelSupportsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (capability === "image") return hasAnyCapability(model, ["text-to-image", "image-to-image"]);
if (capability === "video") return hasAnyCapability(model, ["text-to-video", "image-to-video"]);
if (capability === "3d") return hasAnyCapability(model, ["text-to-3d", "image-to-3d"]);
if (capability === "llm") return hasAnyCapability(model, ["text-to-text"]);
return hasAnyCapability(model, ["text-to-audio"]);
}
export function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (selectedModelCapabilities(model).length === 0) return true;
return modelSupportsCapability(model, capability);
}
export function inferCapabilityFromModel(model: SelectedModel): Exclude<ComposerCapability, "all"> | null {
if (modelSupportsCapability(model, "video")) return "video";
if (modelSupportsCapability(model, "3d")) return "3d";
if (modelSupportsCapability(model, "audio")) return "audio";
if (modelSupportsCapability(model, "image")) return "image";
if (modelSupportsCapability(model, "llm")) return "llm";
return null;
}
export function inferNodeTypeFromModel(model: SelectedModel): NodeType | null {
const capability = inferCapabilityFromModel(model);
if (capability === "video") return "smartVideo";
if (capability === "3d") return "generate3d";
if (capability === "audio") return "smartAudio";
if (capability === "image") return "smartImage";
if (capability === "llm") return "smartText";
return null;
}
export function preferenceTypeForCapability(capability: ComposerCapability): "image" | "video" | "audio" | null {
if (capability === "image") return "image";
if (capability === "video") return "video";
if (capability === "audio") return "audio";
return null;
}
export function getConcreteGenerationNodeType(nodeType: NodeType | null): NodeType | null {
if (nodeType === "smartImage") return "nanoBanana";
if (nodeType === "smartVideo") return "generateVideo";
if (nodeType === "smartAudio") return "generateAudio";
return null;
}

70
src/components/composer/composerReferenceEdges.ts

@ -0,0 +1,70 @@
import { getNodeOutputMediaType } from "@/utils/nodeHandles";
import type {
ComposerReferenceInput,
ComposerReferenceMaterialType,
} from "@/utils/composerReferenceSubjects";
import type { WorkflowEdge, WorkflowNode } from "@/types";
export function finiteNumberFromUnknown(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function getConnectedEdgesByType(
targetNodeId: string,
nodeById: Map<string, WorkflowNode>,
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType
): Array<{ id: string; sourceNodeId: string }> {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const sourceNode = nodeById.get(edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => ({ id: edge.id, sourceNodeId: edge.source }));
}
export function getConnectedEdgeIdsByType(
targetNodeId: string,
nodeById: Map<string, WorkflowNode>,
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType | "text"
): string[] {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const sourceNode = nodeById.get(edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => edge.id);
}
export function getReferenceImageThumbnailUrl(sourceNode: WorkflowNode | undefined): string | undefined {
const previewImg = (sourceNode?.data as { previewImg?: unknown } | undefined)?.previewImg;
return typeof previewImg === "string" && previewImg.length > 0 ? previewImg : undefined;
}
export function getReferenceVideoDurationSeconds(videos: ComposerReferenceInput[]): number {
const seenUrls = new Set<string>();
const totalDuration = videos.reduce((sum, video) => {
if (seenUrls.has(video.url)) return sum;
seenUrls.add(video.url);
const duration = finiteNumberFromUnknown(video.duration);
return duration !== null && duration > 0 ? sum + duration : sum;
}, 0);
return Math.ceil(totalDuration);
}
export function uniqueReferenceVideoInputs(videos: ComposerReferenceInput[]): ComposerReferenceInput[] {
const seenUrls = new Set<string>();
return videos.filter((video) => {
if (seenUrls.has(video.url)) return false;
seenUrls.add(video.url);
return true;
});
}

2
src/lib/providers/types.ts

@ -16,11 +16,9 @@ export type ModelCapability =
| "image-to-image"
| "text-to-video"
| "image-to-video"
| "video-to-video"
| "text-to-3d"
| "image-to-3d"
| "text-to-audio"
| "audio-to-video"
| "text-to-text";
/**

16
src/store/execution/__tests__/llmGenerateExecutor.test.ts

@ -129,7 +129,14 @@ describe("executeLlmGenerate", () => {
});
it("should include images in request when connected", async () => {
const node = makeNode();
const node = makeNode({
selectedModel: {
provider: "popiserver",
modelId: "doubao-seed-2-0-lite-260428",
displayName: "Doubao",
isSupportImages: true,
},
});
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: ["data:image/png;base64,aW1nMQ=="],
@ -200,6 +207,7 @@ describe("executeLlmGenerate", () => {
provider: "popiserver",
modelId: "31",
displayName: "Kimi K2.6",
isSupportImages: true,
metadata: { aiModelId: 31 },
},
});
@ -331,6 +339,12 @@ describe("executeLlmGenerate", () => {
it("should use stored fallback in regenerate mode", async () => {
const node = makeNode({
selectedModel: {
provider: "popiserver",
modelId: "doubao-seed-2-0-lite-260428",
displayName: "Doubao",
isSupportImages: true,
},
inputImages: ["stored.png"],
inputPrompt: "stored llm prompt",
});

2
src/store/execution/popiMediaSupport.ts

@ -11,7 +11,7 @@ export function getPopiSupportedMediaInputs(
): { images: string[]; videos: string[]; voices: string[] } {
const metadata = selectedModelMetadata(model);
return {
images: metadata?.isSupportImages === false ? [] : inputs.images ?? [],
images: metadata?.isSupportImages === true ? inputs.images ?? [] : [],
videos: metadata?.isSupportVideos === true ? inputs.videos ?? [] : [],
voices: metadata?.isSupportAudios === true ? inputs.voices ?? [] : [],
};

4
src/store/workflowStore.ts

@ -841,7 +841,7 @@ function buildStoreManagedGenerationData(
function getUnsupportedReferenceEdgeIds(nodeId: string, get: () => WorkflowStore): string[] {
const node = get().nodes.find((candidate) => candidate.id === nodeId);
if (!node) return [];
const data = node.data as { selectedModel?: SelectedModel; parameters?: Record<string, unknown> };
const data = node.data as { selectedModel?: SelectedModel };
const model = normalizeSelectedModel(data.selectedModel);
if (!model) return [];
return get().edges
@ -850,7 +850,7 @@ function getUnsupportedReferenceEdgeIds(nodeId: string, get: () => WorkflowStore
const sourceNode = get().nodes.find((candidate) => candidate.id === edge.source);
const mediaType = sourceNode ? getNodeOutputMediaType(sourceNode) : null;
if (mediaType !== "image" && mediaType !== "video" && mediaType !== "audio") return false;
return !modelSupportsReferenceMedia(model, mediaType, data.parameters);
return !modelSupportsReferenceMedia(model, mediaType);
})
.map((edge) => edge.id);
}

4
src/utils/__tests__/generationInputSupport.test.ts

@ -6,7 +6,9 @@ const popiVideoModel: SelectedModel = {
provider: "popiserver",
modelId: "1",
displayName: "Popi Video",
capabilities: ["image-to-video", "video-to-video", "audio-to-video"],
isSupportImages: true,
isSupportVideos: true,
isSupportAudios: true,
};
describe("generationInputSupport", () => {

82
src/utils/generationInputSupport.ts

@ -1,88 +1,24 @@
import type { SelectedModel } from "@/types";
import { selectedModelCapabilities, selectedModelMetadataValue } from "@/utils/selectedModel";
import { selectedModelMetadataValue } from "@/utils/selectedModel";
export type ReferenceMediaType = "image" | "video" | "audio";
const VIDEO_INPUT_PARAMETER_NAMES = new Set([
"video",
"videos",
"video_url",
"video_urls",
"reference_video",
"reference_video_url",
"reference_video_urls",
]);
const AUDIO_INPUT_PARAMETER_NAMES = new Set([
"voice",
"voices",
"audio",
"audios",
"audio_url",
"audio_urls",
"reference_audio",
"reference_audio_url",
"reference_audio_urls",
]);
const IMAGE_INPUT_PARAMETER_NAMES = new Set([
"image",
"images",
"image_url",
"image_urls",
"first_frame",
"first_frame_url",
"last_frame",
"last_frame_url",
"tail_image_url",
"reference_image",
"reference_image_url",
"reference_image_urls",
]);
function hasNamedParameterInput(parameters: Record<string, unknown> | undefined, names: Set<string>): boolean {
if (!parameters) return false;
return Object.keys(parameters).some((key) => names.has(key));
}
function metadataBoolean(model: SelectedModel, key: string): boolean | null {
const value = selectedModelMetadataValue(model, key);
return typeof value === "boolean" ? value : null;
function metadataBoolean(model: SelectedModel, key: string): boolean {
return selectedModelMetadataValue(model, key) === true;
}
export function modelSupportsReferenceMedia(
model: SelectedModel,
mediaType: ReferenceMediaType,
parameters?: Record<string, unknown>
mediaType: ReferenceMediaType
): boolean {
if (mediaType === "image") {
const supportImages = metadataBoolean(model, "isSupportImages");
if (supportImages !== null) return supportImages;
if (hasNamedParameterInput(parameters, IMAGE_INPUT_PARAMETER_NAMES)) return true;
return Boolean(selectedModelCapabilities(model).some((capability) =>
capability === "image-to-image" ||
capability === "image-to-video" ||
capability === "image-to-3d"
));
}
if (mediaType === "video") {
const supportVideos = metadataBoolean(model, "isSupportVideos");
if (supportVideos !== null) return supportVideos;
if (hasNamedParameterInput(parameters, VIDEO_INPUT_PARAMETER_NAMES)) return true;
return Boolean(selectedModelCapabilities(model).includes("video-to-video"));
}
const supportAudios = metadataBoolean(model, "isSupportAudios");
if (supportAudios !== null) return supportAudios;
if (hasNamedParameterInput(parameters, AUDIO_INPUT_PARAMETER_NAMES)) return true;
return Boolean(selectedModelCapabilities(model).includes("audio-to-video"));
if (mediaType === "image") return metadataBoolean(model, "isSupportImages");
if (mediaType === "video") return metadataBoolean(model, "isSupportVideos");
return metadataBoolean(model, "isSupportAudios");
}
export function filterReferenceSubjectList(
referenceSubjectList: unknown,
model: SelectedModel,
parameters?: Record<string, unknown>
model: SelectedModel
): unknown {
if (!Array.isArray(referenceSubjectList)) return referenceSubjectList;
let nextId = 1;
@ -91,7 +27,7 @@ export function filterReferenceSubjectList(
const subject = item as Record<string, unknown>;
const type = subject.type;
if (type !== "image" && type !== "video" && type !== "audio") return [item];
if (!modelSupportsReferenceMedia(model, type, parameters)) return [];
if (!modelSupportsReferenceMedia(model, type)) return [];
const sanitized: Record<string, unknown> = {
id: nextId,

2
src/utils/popiModelClassification.ts

@ -50,7 +50,7 @@ export const POPI_SUBTYPES_BY_KIND: Record<PopiModelMediaKind, number[]> = {
export const POPI_CAPABILITIES_BY_KIND: Record<PopiModelMediaKind, ModelCapability[]> = {
image: ["text-to-image", "image-to-image"],
video: ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"],
video: ["text-to-video", "image-to-video"],
audio: ["text-to-audio"],
llm: ["text-to-text"],
multiAngle: ["image-to-image"],

8
src/utils/popiserverTaskPayload.ts

@ -133,11 +133,13 @@ export function buildPopiTaskBody(
asNumber(input.model.metadata?.aiModelId) ??
asNumber(input.model.id) ??
modelDetail?.id;
const images = input.images || [];
const videos = modelSupportsReferenceMedia(selectedModel, "video", rawParameters)
const images = modelSupportsReferenceMedia(selectedModel, "image")
? input.images || []
: [];
const videos = modelSupportsReferenceMedia(selectedModel, "video")
? input.videos || []
: [];
const voices = modelSupportsReferenceMedia(selectedModel, "audio", rawParameters)
const voices = modelSupportsReferenceMedia(selectedModel, "audio")
? input.voices || []
: [];
const voiceId =

2
src/utils/videoGenerationSettings.ts

@ -1,5 +1,4 @@
import type { SelectedModel } from "@/types";
import { selectedModelCapabilities } from "@/utils/selectedModel";
export const MIN_VIDEO_DURATION_SECONDS = 1;
export const DEFAULT_VIDEO_DURATION_SECONDS = 5;
@ -44,7 +43,6 @@ export function getVideoSoundParameterName(model?: SelectedModel): string | null
if (model?.provider === "kie" && text.includes("seedance")) return "generate_audio";
if (text.includes("kling")) return "sound";
if (text.includes("seedance")) return "sound";
if (selectedModelCapabilities(model).includes("audio-to-video")) return "sound";
return null;
}

Loading…
Cancel
Save