15 changed files with 485 additions and 1634 deletions
File diff suppressed because it is too large
@ -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 {}; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue