You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1452 lines
58 KiB
1452 lines
58 KiB
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type DragEvent as ReactDragEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useReactFlow } from "@xyflow/react";
|
|
import { Modal } from "antd";
|
|
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 { getGenerationNodeSizeForAspectRatio } from "@/utils/nodeDimensions";
|
|
import {
|
|
deriveComposerContext,
|
|
deriveComposerContextForNodeId,
|
|
type ComposerCapability,
|
|
type ComposerContext,
|
|
} from "@/utils/composerNodeDescriptor";
|
|
import {
|
|
buildInitialGenerationNodeData,
|
|
buildSubmittableGenerationConfig,
|
|
filterTaskRoutingParameters,
|
|
readAudioGenerationConfig,
|
|
readImageGenerationConfig,
|
|
readVideoGenerationConfig,
|
|
type GenerationNodeConfig,
|
|
type PopiserverTaskPayloadMedia,
|
|
} from "@/utils/generationConfig";
|
|
import {
|
|
buildComposerReferenceMaterials,
|
|
getReferenceMaterialKey,
|
|
orderReferenceMaterials,
|
|
readReferenceSubjectList,
|
|
syncPromptReferenceMaterials,
|
|
type ComposerReferenceInput,
|
|
type ComposerReferenceMaterial,
|
|
type ComposerReferenceMaterialType,
|
|
} from "@/utils/composerReferenceSubjects";
|
|
import {
|
|
buildPopiserverPriceQuoteRequest,
|
|
getQuotedPointAmount,
|
|
type PopiserverPriceQuoteResponse,
|
|
} from "@/utils/composerPricing";
|
|
import {
|
|
buildComposerPricingContext,
|
|
buildFinalComposerPrompt,
|
|
} from "@/utils/composerPricingContext";
|
|
import { selectedModelCapabilities } from "@/utils/selectedModel";
|
|
import {
|
|
ComposerPromptMaterials,
|
|
type ComposerConnectedTextCard,
|
|
} from "@/components/composer/ComposerPromptMaterials";
|
|
import { ComposerModelConfig } from "@/components/composer/ComposerModelConfig";
|
|
import { ComposerProcessBody } from "@/components/composer/ComposerProcessBody";
|
|
import type { ComposerMentionReferenceItem } from "@/components/composer/ComposerMentionInput";
|
|
import type {
|
|
EaseCurveNodeData,
|
|
GenerateAudioNodeData,
|
|
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;
|
|
|
|
type ComposerPreferenceOptions = {
|
|
nodeSize?: { width: number; height: number } | null;
|
|
};
|
|
|
|
type PricingInputsSnapshot = {
|
|
config: ComposerConfig;
|
|
prompt: string;
|
|
activeModelDetail: PopiModelDetail | undefined;
|
|
modelSchema: ModelParameter[];
|
|
extensionFields?: unknown;
|
|
connectedInputs: ConnectedInputs | null;
|
|
connectedVideoDurationSeconds: number;
|
|
popiserverPricingMedia: PopiserverTaskPayloadMedia;
|
|
isHidden: boolean;
|
|
};
|
|
|
|
const COMPOSER_WIDTH = 630;
|
|
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 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 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 readWorkflowNodeSize(node: WorkflowNode | null | undefined): { width: number; height: number } | null {
|
|
if (!node) return null;
|
|
const width = typeof node.measured?.width === "number"
|
|
? node.measured.width
|
|
: typeof node.style?.width === "number"
|
|
? node.style.width
|
|
: typeof node.width === "number"
|
|
? node.width
|
|
: null;
|
|
const height = typeof node.measured?.height === "number"
|
|
? node.measured.height
|
|
: typeof node.style?.height === "number"
|
|
? node.style.height
|
|
: typeof node.height === "number"
|
|
? node.height
|
|
: null;
|
|
return width && height && Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
|
|
? { width, height }
|
|
: null;
|
|
}
|
|
|
|
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") {
|
|
return {
|
|
inputPrompt: config.prompt.trim(),
|
|
selectedModel: config.selectedModel,
|
|
batchSize: config.batchSize,
|
|
parameters,
|
|
extraTaskParams: config.extraTaskParams ?? {},
|
|
} 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 readComposerAspectRatio(config: ComposerConfig): string | undefined {
|
|
const value = config.parameters?.ratio ?? config.parameters?.aspectRatio ?? config.parameters?.aspect_ratio;
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function IconButton({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
title={label}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ComposerFrame({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div data-testid="node-inline-composer" data-composer-popup-root="true" className="pointer-events-none">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface GenerationComposerProps {
|
|
targetNodeId: string;
|
|
embedded?: boolean;
|
|
isHidden?: boolean;
|
|
onHiddenPersisted?: () => void;
|
|
}
|
|
|
|
interface ComposerEditorProps {
|
|
context: ComposerContext;
|
|
contextKey: string;
|
|
initialDraft: ComposerDraft;
|
|
nodes: WorkflowNode[];
|
|
edges: WorkflowEdge[];
|
|
dimmedNodeIds: Set<string>;
|
|
isCurrentNodeRunning: boolean;
|
|
embedded: boolean;
|
|
isHidden: boolean;
|
|
onRun: (context: ComposerContext, prompt: string, config: ComposerConfig) => Promise<void>;
|
|
onCancelRunning: () => void;
|
|
onRemoveEdge: (edgeId: string) => void;
|
|
onApplyNodeSize: (nodeId: string, aspectRatio: unknown) => void;
|
|
onModelNeedsDetail: (modelId: string) => Promise<PopiModelDetail | null>;
|
|
onPersistConfig: (context: ComposerContext, config: GenerationNodeConfig) => void;
|
|
onHiddenPersisted?: () => void;
|
|
}
|
|
|
|
export function GenerationComposer({ targetNodeId, embedded = false, isHidden = false, onHiddenPersisted }: GenerationComposerProps) {
|
|
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);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
|
|
const cancelNodeTask = useWorkflowStore((state) => state.cancelNodeTask);
|
|
const removeEdge = useWorkflowStore((state) => state.removeEdge);
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
const onNodesChange = useWorkflowStore((state) => state.onNodesChange);
|
|
const fetchModelDetail = useModelStore((state) => state.fetchModelDetail);
|
|
const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference);
|
|
const updateVideoPreference = useGenerationPreferenceStore((state) => state.updateVideoPreference);
|
|
const updateAudioPreference = useGenerationPreferenceStore((state) => state.updateAudioPreference);
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
|
|
const context = useMemo(
|
|
() => {
|
|
const selectedNodes = nodes.filter((node) => node.selected);
|
|
if (!isHidden && selectedNodes.length > 1) {
|
|
return deriveComposerContext(nodes, edges);
|
|
}
|
|
const targetNode = nodes.find((node) => node.id === targetNodeId);
|
|
const selectedNode = selectedNodes[0];
|
|
const effectiveTargetNodeId = !isHidden && selectedNodes.length === 1 && selectedNode && selectedNode.id !== targetNodeId
|
|
? selectedNode.id
|
|
: targetNode?.id;
|
|
return effectiveTargetNodeId
|
|
? deriveComposerContextForNodeId(nodes, edges, effectiveTargetNodeId)
|
|
: deriveComposerContextForNodeId(
|
|
nodes,
|
|
edges,
|
|
selectedNode?.id ?? targetNodeId
|
|
);
|
|
},
|
|
[edges, isHidden, nodes, targetNodeId]
|
|
);
|
|
const contextKey = contextIdentity(context);
|
|
const initialConfig = useMemo(() => readDraftFromContext(context), [contextKey, context]);
|
|
const activeModelId = initialConfig.selectedModel.modelId;
|
|
const activeComposerNodeId =
|
|
(context.mode === "node-edit" || context.mode === "process-node" || context.mode === "unsupported-node")
|
|
? context.node?.id ?? null
|
|
: null;
|
|
const isCurrentNodeRunning = activeComposerNodeId ? runningNodeIds.has(activeComposerNodeId) : false;
|
|
|
|
useEffect(() => {
|
|
if (isHidden || !embedded || !activeModelId) return;
|
|
void fetchModelDetail(activeModelId);
|
|
}, [activeModelId, embedded, fetchModelDetail, isHidden]);
|
|
|
|
const applyNodeSize = useCallback((nodeId: string, aspectRatioValue: unknown) => {
|
|
const nextSize = getGenerationNodeSizeForAspectRatio(aspectRatioValue);
|
|
if (!nextSize) return;
|
|
onNodesChange([{ id: nodeId, type: "dimensions", dimensions: nextSize, setAttributes: true }]);
|
|
}, [onNodesChange]);
|
|
|
|
const buildTargetPosition = useCallback((size?: { width: number; height: number }) => {
|
|
const width = size?.width ?? 300;
|
|
const height = size?.height ?? 280;
|
|
return screenToFlowPosition({
|
|
x: window.innerWidth / 2 - width / 2,
|
|
y: window.innerHeight / 2 - height / 2,
|
|
});
|
|
}, [screenToFlowPosition]);
|
|
|
|
const rememberGenerationPreference = useCallback((
|
|
capability: ComposerCapability,
|
|
nextConfig: ComposerConfig,
|
|
options: ComposerPreferenceOptions = {}
|
|
) => {
|
|
const preferenceType = preferenceTypeForCapability(capability);
|
|
const patch = {
|
|
...nextConfig,
|
|
model: nextConfig.selectedModel,
|
|
...(options.nodeSize ? { nodeSize: options.nodeSize } : {}),
|
|
};
|
|
if (preferenceType === "image") {
|
|
updateImagePreference(patch);
|
|
return;
|
|
}
|
|
if (preferenceType === "video") {
|
|
updateVideoPreference(patch);
|
|
return;
|
|
}
|
|
if (preferenceType === "audio") {
|
|
updateAudioPreference(patch);
|
|
}
|
|
}, [updateAudioPreference, updateImagePreference, updateVideoPreference]);
|
|
|
|
const runComposer = useCallback(async (runContext: ComposerContext, prompt: string, config: ComposerConfig) => {
|
|
const generationConfig = buildComposerGenerationConfig(config, prompt);
|
|
let submitNode = runContext.node;
|
|
if (runContext.mode === "node-edit" && runContext.node) {
|
|
submitNode = useWorkflowStore.getState().nodes.find((node) => node.id === runContext.node?.id) ?? runContext.node;
|
|
}
|
|
const connectedInputs =
|
|
(runContext.mode === "node-edit" || runContext.mode === "process-node") && submitNode
|
|
? useWorkflowStore.getState().getConnectedInputs(submitNode.id)
|
|
: null;
|
|
const finalPrompt = buildFinalComposerPrompt(prompt, connectedInputs);
|
|
const submitNodeType = runContext.mode === "empty-create" ? inferNodeTypeFromModel(config.selectedModel) : runContext.nodeType;
|
|
|
|
const submitModelDetail = generationConfig.selectedModel.modelId ? await fetchModelDetail(generationConfig.selectedModel.modelId) : null;
|
|
const submitConfig = buildSubmittableGenerationConfig({
|
|
...generationConfig,
|
|
prompt: finalPrompt,
|
|
}, submitModelDetail);
|
|
const submitCapability = runContext.mode === "empty-create"
|
|
? inferCapabilityFromModel(submitConfig.selectedModel)
|
|
: runContext.capability;
|
|
const submitAspectRatio = readComposerAspectRatio(submitConfig);
|
|
const submitNodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio) ?? readWorkflowNodeSize(submitNode);
|
|
if (submitCapability) rememberGenerationPreference(submitCapability, submitConfig, { nodeSize: submitNodeSize });
|
|
|
|
if (runContext.mode === "unsupported-node" && runContext.node?.type === "imageInput") {
|
|
const imageData = runContext.node.data as ImageInputNodeData;
|
|
if (!imageData.image) return;
|
|
const selectedModel = modelSupportsCapability(submitConfig.selectedModel, "image")
|
|
? submitConfig.selectedModel
|
|
: EMPTY_SELECTED_MODEL;
|
|
if (!selectedModel.modelId) return;
|
|
const nextNodeId = addNode(
|
|
"smartImage",
|
|
buildTargetPosition(submitNodeSize ?? undefined),
|
|
buildInitialDataForNode("smartImage", {
|
|
...submitConfig,
|
|
selectedModel,
|
|
inputImages: [imageData.image],
|
|
}),
|
|
submitNodeSize ?? undefined
|
|
);
|
|
selectSingleNode(nextNodeId);
|
|
await regenerateNode(nextNodeId);
|
|
return;
|
|
}
|
|
|
|
if (runContext.mode === "empty-create") {
|
|
if (!submitNodeType) return;
|
|
const nextNodeId = addNode(
|
|
submitNodeType,
|
|
buildTargetPosition(submitNodeSize ?? undefined),
|
|
buildInitialDataForNode(submitNodeType, submitConfig),
|
|
submitNodeSize ?? undefined
|
|
);
|
|
selectSingleNode(nextNodeId);
|
|
await regenerateNode(nextNodeId);
|
|
return;
|
|
}
|
|
|
|
if (runContext.mode === "node-edit" && submitNode) {
|
|
const concreteNodeType = getConcreteGenerationNodeType(submitNode.type);
|
|
if (concreteNodeType) {
|
|
convertNodeType(submitNode.id, concreteNodeType, {
|
|
...buildInitialDataForNode(concreteNodeType, submitConfig),
|
|
inputPrompt: prompt.trim(),
|
|
referenceSubjectList: generationConfig.referenceSubjectList,
|
|
});
|
|
applyNodeSize(submitNode.id, submitAspectRatio);
|
|
await regenerateNode(submitNode.id);
|
|
return;
|
|
}
|
|
updateNodeData(submitNode.id, {
|
|
...buildInitialDataForNode(submitNode.type, submitConfig),
|
|
inputPrompt: prompt.trim(),
|
|
referenceSubjectList: generationConfig.referenceSubjectList,
|
|
});
|
|
await regenerateNode(submitNode.id);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
runContext.mode === "process-node" &&
|
|
(runContext.node?.type === "videoStitch" || runContext.node?.type === "easeCurve")
|
|
) {
|
|
await regenerateNode(runContext.node.id);
|
|
}
|
|
}, [
|
|
addNode,
|
|
applyNodeSize,
|
|
buildTargetPosition,
|
|
convertNodeType,
|
|
fetchModelDetail,
|
|
rememberGenerationPreference,
|
|
regenerateNode,
|
|
selectSingleNode,
|
|
updateNodeData,
|
|
]);
|
|
|
|
const persistComposerConfig = useCallback((persistContext: ComposerContext, config: GenerationNodeConfig) => {
|
|
const currentNode = persistContext.node
|
|
? useWorkflowStore.getState().nodes.find((node) => node.id === persistContext.node?.id) ?? persistContext.node
|
|
: null;
|
|
rememberGenerationPreference(persistContext.capability, config, { nodeSize: readWorkflowNodeSize(currentNode) });
|
|
if (!currentNode || persistContext.mode !== "node-edit") return;
|
|
const patch = buildComposerConfigPersistPatch(currentNode, config);
|
|
if (Object.keys(patch).length === 0) return;
|
|
updateNodeData(currentNode.id, patch);
|
|
}, [rememberGenerationPreference, updateNodeData]);
|
|
|
|
const cancelRunning = useCallback(() => {
|
|
if (!activeComposerNodeId) return;
|
|
Modal.confirm({
|
|
title: t("composer.cancelTaskTitle"),
|
|
content: t("composer.cancelTaskConfirm"),
|
|
okText: t("composer.cancelTaskOk"),
|
|
cancelText: t("composer.cancelTaskContinue"),
|
|
okButtonProps: { danger: true },
|
|
onOk: () => cancelNodeTask(activeComposerNodeId),
|
|
});
|
|
}, [activeComposerNodeId, cancelNodeTask, t]);
|
|
|
|
if (context.mode === "multi-select" || (context.mode === "unsupported-node" && !context.node)) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ComposerEditor
|
|
context={context}
|
|
contextKey={contextKey}
|
|
initialDraft={initialConfig}
|
|
nodes={nodes}
|
|
edges={edges}
|
|
dimmedNodeIds={dimmedNodeIds}
|
|
isCurrentNodeRunning={isCurrentNodeRunning}
|
|
embedded={embedded}
|
|
isHidden={isHidden}
|
|
onRun={runComposer}
|
|
onCancelRunning={cancelRunning}
|
|
onRemoveEdge={removeEdge}
|
|
onApplyNodeSize={applyNodeSize}
|
|
onModelNeedsDetail={(modelId) => fetchModelDetail(modelId)}
|
|
onPersistConfig={persistComposerConfig}
|
|
onHiddenPersisted={onHiddenPersisted}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ComposerEditor({
|
|
context,
|
|
contextKey,
|
|
initialDraft,
|
|
nodes,
|
|
edges,
|
|
dimmedNodeIds,
|
|
isCurrentNodeRunning,
|
|
embedded,
|
|
isHidden,
|
|
onRun,
|
|
onCancelRunning,
|
|
onRemoveEdge,
|
|
onApplyNodeSize,
|
|
onModelNeedsDetail,
|
|
onPersistConfig,
|
|
onHiddenPersisted,
|
|
}: ComposerEditorProps) {
|
|
void dimmedNodeIds;
|
|
void embedded;
|
|
const { t } = useI18n();
|
|
const promptInputRef = useRef<HTMLDivElement>(null);
|
|
const referenceAudioPreviewRef = useRef<HTMLAudioElement | null>(null);
|
|
const referenceMaterialsRef = useRef<ComposerReferenceMaterial[]>([]);
|
|
const [config, setConfig] = useState<ComposerConfig>(() => configFromDraft(initialDraft));
|
|
const [prompt, setPrompt] = useState(initialDraft.prompt);
|
|
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
|
|
const [playingReferenceAudioKey, setPlayingReferenceAudioKey] = useState<string | null>(null);
|
|
const [priceState, setPriceState] = useState<{ amount: number | null; isLoading: boolean }>({
|
|
amount: null,
|
|
isLoading: false,
|
|
});
|
|
const priceRequestIdRef = useRef(0);
|
|
const suppressAutoPriceRefreshRef = useRef(false);
|
|
const modelChangeCompletionTimerRef = useRef<number | null>(null);
|
|
const priceRefreshTimerRef = useRef<number | null>(null);
|
|
const pricingInputsRef = useRef<PricingInputsSnapshot>({
|
|
config,
|
|
prompt,
|
|
activeModelDetail: undefined,
|
|
modelSchema: [],
|
|
extensionFields: undefined,
|
|
connectedInputs: null,
|
|
connectedVideoDurationSeconds: 0,
|
|
popiserverPricingMedia: {},
|
|
isHidden,
|
|
});
|
|
const reorderNodeReferenceSubjectList = useWorkflowStore((state) => state.reorderNodeReferenceSubjectList);
|
|
const updateNodeExtraTaskParams = useWorkflowStore((state) => state.updateNodeExtraTaskParams);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const buildGenerationConfigForContext = useCallback((
|
|
targetContext: ComposerContext,
|
|
nextConfig: ComposerConfig = config,
|
|
nextPrompt: string = prompt
|
|
) => {
|
|
if (targetContext.mode !== "node-edit" || !targetContext.node) {
|
|
return buildComposerGenerationConfig(nextConfig, nextPrompt);
|
|
}
|
|
const latestNode = useWorkflowStore.getState().nodes.find((node) => node.id === targetContext.node?.id);
|
|
const latestSubjects = latestNode
|
|
? readReferenceSubjectList((latestNode.data as { referenceSubjectList?: unknown }).referenceSubjectList)
|
|
: readReferenceSubjectList((targetContext.node.data as { referenceSubjectList?: unknown }).referenceSubjectList);
|
|
return buildComposerGenerationConfig({
|
|
...nextConfig,
|
|
referenceSubjectList: latestSubjects,
|
|
}, nextPrompt);
|
|
}, [config, prompt]);
|
|
const persistCurrentComposerConfig = useCallback(() => {
|
|
onPersistConfig(context, buildGenerationConfigForContext(context));
|
|
}, [buildGenerationConfigForContext, context, onPersistConfig]);
|
|
|
|
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]);
|
|
|
|
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");
|
|
return connectedTextItems.map((text, index) => ({
|
|
text,
|
|
...(textEdgeIds[index] ? { edgeId: textEdgeIds[index] } : {}),
|
|
}));
|
|
}, [connectedTextItems, context.node, edges, nodes]);
|
|
|
|
const currentModelId = config.selectedModel.modelId;
|
|
const activeModelDetail = useModelStore((state) => currentModelId ? state.detailsById[currentModelId] : undefined);
|
|
const modelSchema = activeModelDetail?.parameters ?? [];
|
|
const extensionFields = activeModelDetail?.extensionFields;
|
|
|
|
const activeCapability = context.mode === "empty-create"
|
|
? inferCapabilityFromModel(config.selectedModel) ?? "image"
|
|
: context.capability;
|
|
const isProcessNode = context.mode === "process-node" && (context.node?.type === "videoStitch" || context.node?.type === "easeCurve");
|
|
const modelIsValid = context.mode === "empty-create"
|
|
? Boolean(inferNodeTypeFromModel(config.selectedModel))
|
|
: storedModelFitsCapability(config.selectedModel, context.capability);
|
|
const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit";
|
|
|
|
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
|
|
const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : [];
|
|
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : config.inputImages;
|
|
const estimateReferenceVideoInputs = useMemo<ComposerReferenceInput[]>(() => {
|
|
const videoItems = context.mode === "node-edit" ? connectedInputs?.videoItems ?? [] : [];
|
|
const videoEdges =
|
|
context.mode === "node-edit" && context.node
|
|
? getConnectedEdgesByType(context.node.id, nodes, edges, "video")
|
|
: [];
|
|
return uniqueReferenceVideoInputs(videoItems.map((video, index) => ({
|
|
url: video.url,
|
|
...(video.duration && video.duration > 0 ? { duration: video.duration } : {}),
|
|
...(videoEdges[index]?.id ? { edgeId: videoEdges[index].id } : {}),
|
|
...(videoEdges[index]?.sourceNodeId ? { sourceNodeId: videoEdges[index].sourceNodeId } : {}),
|
|
})));
|
|
}, [connectedInputs?.videoItems, context.mode, context.node, edges, nodes]);
|
|
|
|
const referenceImageInputs = useMemo<ComposerReferenceInput[]>(() => {
|
|
const imageEdges =
|
|
context.mode === "node-edit" && context.node && connectedReferenceImages.length > 0
|
|
? getConnectedEdgesByType(context.node.id, nodes, 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))
|
|
: undefined;
|
|
return {
|
|
url,
|
|
...(thumbnailUrl ? { thumbnailUrl } : {}),
|
|
...(edge?.id ? { edgeId: edge.id } : {}),
|
|
...(sourceNodeId ? { sourceNodeId } : {}),
|
|
};
|
|
});
|
|
}, [connectedReferenceImages.length, context.mode, context.node, edges, nodes, referenceImages]);
|
|
|
|
const referenceAudioInputs = useMemo<ComposerReferenceInput[]>(() => {
|
|
const audioEdges =
|
|
context.mode === "node-edit" && context.node
|
|
? getConnectedEdgesByType(context.node.id, nodes, 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]);
|
|
|
|
const baseReferenceMaterials = useMemo(() => buildComposerReferenceMaterials(
|
|
referenceImageInputs,
|
|
estimateReferenceVideoInputs,
|
|
referenceAudioInputs,
|
|
), [estimateReferenceVideoInputs, referenceAudioInputs, referenceImageInputs]);
|
|
const liveReferenceSubjectOrder = useMemo(() => {
|
|
if (context.mode !== "node-edit" || !context.node) return [];
|
|
const nodeData = context.node.data as { referenceSubjectList?: unknown };
|
|
return readReferenceSubjectList(nodeData.referenceSubjectList).map((item) => item.id);
|
|
}, [context.mode, context.node]);
|
|
const liveReferenceSubjectOrderKey = useMemo(
|
|
() => liveReferenceSubjectOrder.join("|"),
|
|
[liveReferenceSubjectOrder]
|
|
);
|
|
const referenceMaterials = useMemo(
|
|
() => orderReferenceMaterials(baseReferenceMaterials, liveReferenceSubjectOrder),
|
|
[baseReferenceMaterials, liveReferenceSubjectOrderKey]
|
|
);
|
|
const referenceMaterialsKey = useMemo(
|
|
() => referenceMaterials
|
|
.map((material) => `${getReferenceMaterialKey(material)}:${material.type}:${material.url}:${material.name}:${material.thumbnailUrl ?? ""}:${material.duration ?? ""}`)
|
|
.join("|"),
|
|
[referenceMaterials]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const previousMaterials = referenceMaterialsRef.current.length > 0
|
|
? referenceMaterialsRef.current
|
|
: referenceSubjectsToMaterials(config.referenceSubjectList);
|
|
referenceMaterialsRef.current = referenceMaterials;
|
|
const nextPrompt = syncPromptReferenceMaterials(prompt, previousMaterials, referenceMaterials);
|
|
if (nextPrompt === prompt) return;
|
|
setPrompt(nextPrompt);
|
|
}, [config.referenceSubjectList, prompt, referenceMaterialsKey, referenceMaterials]);
|
|
|
|
const connectedVideoDurationSeconds = useMemo(
|
|
() => getReferenceVideoDurationSeconds(estimateReferenceVideoInputs),
|
|
[estimateReferenceVideoInputs]
|
|
);
|
|
const popiserverPricingMedia = useMemo(() => ({
|
|
images: referenceImages,
|
|
videos: estimateReferenceVideoInputs.map((input) => input.url),
|
|
voices: referenceAudioInputs.map((input) => input.url),
|
|
}), [estimateReferenceVideoInputs, referenceAudioInputs, referenceImages]);
|
|
|
|
pricingInputsRef.current = {
|
|
config,
|
|
prompt,
|
|
activeModelDetail,
|
|
modelSchema,
|
|
extensionFields,
|
|
connectedInputs,
|
|
connectedVideoDurationSeconds,
|
|
popiserverPricingMedia,
|
|
isHidden,
|
|
};
|
|
|
|
const refreshPrice = useCallback(async (
|
|
nextConfig: ComposerConfig,
|
|
nextPrompt = prompt,
|
|
detailOverride?: PopiModelDetail | null,
|
|
inputs: PricingInputsSnapshot = pricingInputsRef.current
|
|
) => {
|
|
const requestId = priceRequestIdRef.current + 1;
|
|
priceRequestIdRef.current = requestId;
|
|
|
|
if (!nextConfig.selectedModel.modelId) {
|
|
setPriceState({ amount: null, isLoading: false });
|
|
return;
|
|
}
|
|
|
|
setPriceState({ amount: null, isLoading: true });
|
|
|
|
const detail = detailOverride !== undefined
|
|
? detailOverride
|
|
: await onModelNeedsDetail(nextConfig.selectedModel.modelId);
|
|
|
|
if (priceRequestIdRef.current !== requestId) return;
|
|
|
|
const generationConfig = buildComposerGenerationConfig(nextConfig, nextPrompt);
|
|
const normalizedConfig = buildSubmittableGenerationConfig(
|
|
generationConfig,
|
|
detail ?? inputs.activeModelDetail ?? { parameters: inputs.modelSchema, extensionFields: inputs.extensionFields }
|
|
);
|
|
const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext({
|
|
config: normalizedConfig,
|
|
prompt: nextPrompt,
|
|
capability: context.capability,
|
|
connectedInputs: inputs.connectedInputs,
|
|
modelDetail: detail,
|
|
media: inputs.popiserverPricingMedia,
|
|
inputVideoDurationSeconds: inputs.connectedVideoDurationSeconds,
|
|
}));
|
|
if (!quoteRequest) {
|
|
setPriceState({ amount: null, isLoading: false });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/points/quote", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(quoteRequest),
|
|
});
|
|
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 {
|
|
if (priceRequestIdRef.current === requestId) setPriceState({ amount: null, isLoading: false });
|
|
}
|
|
}, [
|
|
context.capability,
|
|
context.mode,
|
|
context.node,
|
|
onModelNeedsDetail,
|
|
prompt,
|
|
]);
|
|
|
|
const handleConfigChange = useCallback((config: ComposerConfig) => {
|
|
setConfig(config);
|
|
onPersistConfig(context, buildGenerationConfigForContext(context, config, prompt));
|
|
}, [buildGenerationConfigForContext, context, onPersistConfig, prompt]);
|
|
|
|
const handleExtraTaskParamsChange = useCallback((values: Record<string, unknown>) => {
|
|
if (context.node) updateNodeExtraTaskParams(context.node.id, values);
|
|
const nextConfig = {
|
|
...config,
|
|
extraTaskParams: values,
|
|
};
|
|
setConfig(nextConfig);
|
|
}, [config, context.node, updateNodeExtraTaskParams]);
|
|
|
|
const schedulePriceRefresh = useCallback(() => {
|
|
if (priceRefreshTimerRef.current !== null) {
|
|
window.clearTimeout(priceRefreshTimerRef.current);
|
|
}
|
|
priceRefreshTimerRef.current = window.setTimeout(() => {
|
|
priceRefreshTimerRef.current = null;
|
|
const latest = pricingInputsRef.current;
|
|
if (latest.isHidden) return;
|
|
if (suppressAutoPriceRefreshRef.current) return;
|
|
if (!latest.config.selectedModel.modelId) return;
|
|
void refreshPrice(latest.config, latest.prompt, latest.activeModelDetail, latest);
|
|
}, 200);
|
|
}, [refreshPrice]);
|
|
|
|
const clearScheduledPriceRefresh = useCallback(() => {
|
|
if (priceRefreshTimerRef.current === null) return;
|
|
window.clearTimeout(priceRefreshTimerRef.current);
|
|
priceRefreshTimerRef.current = null;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isHidden) {
|
|
clearScheduledPriceRefresh();
|
|
return;
|
|
}
|
|
if (suppressAutoPriceRefreshRef.current) return;
|
|
if (!config.selectedModel.modelId) return;
|
|
schedulePriceRefresh();
|
|
}, [
|
|
clearScheduledPriceRefresh,
|
|
config.audioVoiceId,
|
|
config.extraTaskParams,
|
|
config.batchSize,
|
|
config.parameters,
|
|
config.referenceSubjectList,
|
|
config.selectedModel.modelId,
|
|
connectedVideoDurationSeconds,
|
|
isHidden,
|
|
popiserverPricingMedia,
|
|
prompt,
|
|
referenceMaterialsKey,
|
|
schedulePriceRefresh,
|
|
]);
|
|
|
|
const hasPromptForSubmit = prompt.trim().length > 0 || connectedTextItems.length > 0;
|
|
const submitNodeType = context.mode === "empty-create" ? inferNodeTypeFromModel(config.selectedModel) : context.nodeType;
|
|
const hasVideoSubTypes =
|
|
activeCapability === "video" &&
|
|
Array.isArray(config.selectedModel.subTypes) &&
|
|
config.selectedModel.subTypes.length > 0;
|
|
const hasReference = referenceMaterials.length > 0;
|
|
const hasRequiredPromptOrReference =
|
|
submitNodeType === "smartAudio" || submitNodeType === "generateAudio" || submitNodeType === "llmGenerate" || submitNodeType === "smartText"
|
|
? hasPromptForSubmit
|
|
: hasPromptForSubmit || hasReference;
|
|
const videoStitchData = context.node?.type === "videoStitch" ? context.node.data as VideoStitchNodeData : null;
|
|
const easeCurveData = context.node?.type === "easeCurve" ? context.node.data as EaseCurveNodeData : null;
|
|
const canRunVideoStitch = Boolean(videoStitchData && !isCurrentNodeRunning && videoStitchData.encoderSupported !== false && (connectedInputs?.videos.length ?? 0) >= 2);
|
|
const canRunEaseCurve = Boolean(easeCurveData && !isCurrentNodeRunning && easeCurveData.encoderSupported !== false && (connectedInputs?.videos.length ?? 0) >= 1);
|
|
const canSubmit =
|
|
canRunVideoStitch ||
|
|
canRunEaseCurve ||
|
|
(!isCurrentNodeRunning && (context.mode === "empty-create" || context.mode === "node-edit") && hasRequiredPromptOrReference && modelIsValid) ||
|
|
(context.mode === "unsupported-node" && context.node?.type === "imageInput" && modelSupportsCapability(config.selectedModel, "image"));
|
|
const canClickSubmit = isCurrentNodeRunning || canSubmit;
|
|
const isPriceLoading = priceState.isLoading;
|
|
const dynamicEstimatedPointAmount = priceState.amount;
|
|
const modelPointCostLabel = dynamicEstimatedPointAmount !== null
|
|
? t("composer.pointCost", { count: formatPointAmount(dynamicEstimatedPointAmount) })
|
|
: null;
|
|
const submitButtonLabel = isCurrentNodeRunning ? "Cancel task" : t("composer.generate");
|
|
const priceLabel = modelPointCostLabel ?? (isPriceLoading ? "..." : null);
|
|
|
|
const hasComposerMediaHeader =
|
|
isProcessNode || connectedTextItems.length > 0 || referenceMaterials.length > 0 || hasVideoSubTypes;
|
|
const nodeData = context.node?.data as (NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | VideoStitchNodeData | EaseCurveNodeData | LLMGenerateNodeData | SmartTextNodeData | undefined);
|
|
const errorMessage = nodeData?.status === "error" ? nodeData.error : null;
|
|
|
|
const stopReferenceAudioPreview = useCallback(() => {
|
|
const audio = referenceAudioPreviewRef.current;
|
|
if (audio) {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
audio.onended = null;
|
|
referenceAudioPreviewRef.current = null;
|
|
}
|
|
setPlayingReferenceAudioKey(null);
|
|
}, []);
|
|
|
|
const previousContextRef = useRef<{ key: string; context: ComposerContext } | null>({
|
|
key: contextKey,
|
|
context,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isHidden) return;
|
|
const previous = previousContextRef.current;
|
|
if (!previous) {
|
|
previousContextRef.current = { key: contextKey, context };
|
|
return;
|
|
}
|
|
if (previous.key === contextKey) {
|
|
previousContextRef.current = { key: contextKey, context };
|
|
return;
|
|
}
|
|
|
|
onPersistConfig(previous.context, buildGenerationConfigForContext(previous.context));
|
|
setConfig(configFromDraft(initialDraft));
|
|
setPrompt(initialDraft.prompt);
|
|
referenceMaterialsRef.current = [];
|
|
previousContextRef.current = { key: contextKey, context };
|
|
// The current local draft belongs to the previous composer identity.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [contextKey]);
|
|
|
|
useEffect(() => {
|
|
if (isHidden) {
|
|
persistCurrentComposerConfig();
|
|
stopReferenceAudioPreview();
|
|
onHiddenPersisted?.();
|
|
return;
|
|
}
|
|
setConfig(configFromDraft(initialDraft));
|
|
setPrompt(initialDraft.prompt);
|
|
referenceMaterialsRef.current = [];
|
|
previousContextRef.current = { key: contextKey, context };
|
|
// Hidden composer state is discarded; reopening hydrates from the store.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isHidden]);
|
|
|
|
useEffect(() => () => stopReferenceAudioPreview(), [stopReferenceAudioPreview]);
|
|
|
|
const handleReferenceAudioPreviewToggle = useCallback((material: ComposerReferenceMaterial) => {
|
|
const materialKey = getReferenceMaterialKey(material);
|
|
if (playingReferenceAudioKey === materialKey) {
|
|
stopReferenceAudioPreview();
|
|
return;
|
|
}
|
|
stopReferenceAudioPreview();
|
|
const audio = new Audio(material.url);
|
|
audio.onended = () => {
|
|
if (referenceAudioPreviewRef.current === audio) {
|
|
referenceAudioPreviewRef.current = null;
|
|
setPlayingReferenceAudioKey(null);
|
|
}
|
|
};
|
|
referenceAudioPreviewRef.current = audio;
|
|
void audio.play()
|
|
.then(() => {
|
|
if (referenceAudioPreviewRef.current === audio) setPlayingReferenceAudioKey(materialKey);
|
|
})
|
|
.catch(() => {
|
|
if (referenceAudioPreviewRef.current === audio) {
|
|
referenceAudioPreviewRef.current = null;
|
|
setPlayingReferenceAudioKey(null);
|
|
}
|
|
});
|
|
}, [playingReferenceAudioKey, stopReferenceAudioPreview]);
|
|
|
|
const handleReferenceMaterialDrop = useCallback((event: ReactDragEvent<HTMLElement>, targetMaterial: ComposerReferenceMaterial) => {
|
|
const sourceKey = event.dataTransfer.getData("application/x-popiart-reference-material");
|
|
const sourceMaterial = referenceMaterials.find((material) => getReferenceMaterialKey(material) === sourceKey);
|
|
if (!sourceMaterial || getReferenceMaterialKey(sourceMaterial) === getReferenceMaterialKey(targetMaterial)) return;
|
|
const nextMaterials = [...referenceMaterials];
|
|
const sourceIndex = nextMaterials.findIndex((material) => getReferenceMaterialKey(material) === getReferenceMaterialKey(sourceMaterial));
|
|
const targetIndex = nextMaterials.findIndex((material) => getReferenceMaterialKey(material) === getReferenceMaterialKey(targetMaterial));
|
|
if (sourceIndex < 0 || targetIndex < 0) return;
|
|
const [removed] = nextMaterials.splice(sourceIndex, 1);
|
|
nextMaterials.splice(targetIndex, 0, removed);
|
|
const orderedNextMaterials = orderReferenceMaterials(nextMaterials, nextMaterials.map(getReferenceMaterialKey));
|
|
referenceMaterialsRef.current = orderedNextMaterials;
|
|
const nextPrompt = syncPromptReferenceMaterials(prompt, referenceMaterials, orderedNextMaterials);
|
|
if (nextPrompt !== prompt) {
|
|
setPrompt(nextPrompt);
|
|
}
|
|
if (context.mode === "node-edit" && context.node) {
|
|
reorderNodeReferenceSubjectList(context.node.id, sourceKey, getReferenceMaterialKey(targetMaterial));
|
|
}
|
|
}, [context.mode, context.node, prompt, referenceMaterials, reorderNodeReferenceSubjectList]);
|
|
|
|
const handleModelChangeStart = useCallback(() => {
|
|
suppressAutoPriceRefreshRef.current = true;
|
|
}, []);
|
|
|
|
const handleModelChangeComplete = useCallback((nextConfig: ComposerConfig, detail: PopiModelDetail | null) => {
|
|
if (modelChangeCompletionTimerRef.current !== null) {
|
|
window.clearTimeout(modelChangeCompletionTimerRef.current);
|
|
}
|
|
modelChangeCompletionTimerRef.current = window.setTimeout(() => {
|
|
modelChangeCompletionTimerRef.current = null;
|
|
suppressAutoPriceRefreshRef.current = false;
|
|
const latest = pricingInputsRef.current;
|
|
pricingInputsRef.current = {
|
|
...latest,
|
|
config: nextConfig,
|
|
activeModelDetail: detail ?? latest.activeModelDetail,
|
|
};
|
|
if (latest.isHidden || !nextConfig.selectedModel.modelId) return;
|
|
schedulePriceRefresh();
|
|
}, 0);
|
|
}, [schedulePriceRefresh]);
|
|
|
|
useEffect(() => () => {
|
|
if (modelChangeCompletionTimerRef.current !== null) {
|
|
window.clearTimeout(modelChangeCompletionTimerRef.current);
|
|
}
|
|
if (priceRefreshTimerRef.current !== null) {
|
|
window.clearTimeout(priceRefreshTimerRef.current);
|
|
}
|
|
}, []);
|
|
|
|
const processBody = isProcessNode ? (
|
|
<ComposerProcessBody
|
|
node={context.node}
|
|
nodes={nodes}
|
|
edges={edges}
|
|
videoStitchData={videoStitchData}
|
|
easeCurveData={easeCurveData}
|
|
/>
|
|
) : undefined;
|
|
|
|
const mentionReferenceItems = useMemo<ComposerMentionReferenceItem[]>(() => {
|
|
if (referenceMaterials.length === 0) {
|
|
return [{
|
|
key: "canvas-assets",
|
|
label: t("composer.canvasAssets"),
|
|
value: `@${t("composer.canvasAssets")} `,
|
|
}];
|
|
}
|
|
|
|
return referenceMaterials.map((material) => ({
|
|
key: `${material.type}-${material.id}-${material.url.slice(0, 24)}`,
|
|
label: material.name.replace(/^@/, ""),
|
|
value: `${material.name} `,
|
|
image: material.type === "image" ? material.thumbnailUrl ?? material.url : undefined,
|
|
sourceNodeId: material.sourceNodeId,
|
|
referenceType: material.type,
|
|
url: material.url,
|
|
thumbnailUrl: material.thumbnailUrl,
|
|
duration: material.duration,
|
|
}));
|
|
}, [referenceMaterials, t]);
|
|
|
|
const runAction = useCallback(() => {
|
|
if (isCurrentNodeRunning) {
|
|
onCancelRunning();
|
|
return;
|
|
}
|
|
const generationConfig = buildGenerationConfigForContext(context);
|
|
onPersistConfig(context, generationConfig);
|
|
const { prompt: _prompt, ...nextConfig } = generationConfig;
|
|
void onRun(context, prompt, nextConfig);
|
|
}, [
|
|
buildGenerationConfigForContext,
|
|
context,
|
|
isCurrentNodeRunning,
|
|
onCancelRunning,
|
|
onPersistConfig,
|
|
onRun,
|
|
prompt,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<ComposerFrame>
|
|
<section
|
|
data-testid="node-inline-composer-panel"
|
|
className="nodrag nopan nowheel pointer-events-auto relative flex max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] shadow-[var(--shadow-panel)] backdrop-blur transition-[width] duration-200"
|
|
style={{ width: isComposerCollapsed ? 360 : COMPOSER_WIDTH }}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 text-[var(--text-muted)]">
|
|
<IconButton
|
|
label={isComposerCollapsed ? t("composer.expand") : t("composer.collapse")}
|
|
onClick={() => {
|
|
if (!isComposerCollapsed) persistCurrentComposerConfig();
|
|
setIsComposerCollapsed((value) => !value);
|
|
}}
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
|
{isComposerCollapsed ? <path d="M7 17 17 7M8 7h9v9" /> : <path d="M17 7 7 17M16 17H7V8" />}
|
|
</svg>
|
|
</IconButton>
|
|
</div>
|
|
|
|
{isComposerCollapsed ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsComposerCollapsed(false)}
|
|
className="flex h-14 min-w-0 items-center gap-3 px-3 pr-14 text-left"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-[var(--text-primary)]">
|
|
{prompt || config.selectedModel.displayName || t("composer.describeContent")}
|
|
</span>
|
|
<span className="shrink-0 text-xs text-[var(--text-muted)]">
|
|
{isCurrentNodeRunning ? submitButtonLabel : priceLabel ?? submitButtonLabel}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<>
|
|
<ComposerPromptMaterials
|
|
hasMediaHeader={hasComposerMediaHeader}
|
|
headerStatusContent={undefined}
|
|
activeCapability={activeCapability}
|
|
nodeId={context.mode === "node-edit" ? context.node?.id : undefined}
|
|
selectedModel={config.selectedModel}
|
|
connectedTextCards={connectedTextCards}
|
|
referenceMaterials={referenceMaterials}
|
|
playingReferenceAudioKey={playingReferenceAudioKey}
|
|
onRemoveConnectedEdge={(edgeId) => onRemoveEdge(edgeId)}
|
|
onReferenceMaterialDragStart={(event, material) => {
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
event.dataTransfer.setData("application/x-popiart-reference-material", getReferenceMaterialKey(material));
|
|
}}
|
|
onReferenceMaterialDrop={handleReferenceMaterialDrop}
|
|
onRemoveReferenceMaterial={(material) => material.edgeId && onRemoveEdge(material.edgeId)}
|
|
onToggleReferenceAudio={handleReferenceAudioPreviewToggle}
|
|
onStopReferenceAudio={stopReferenceAudioPreview}
|
|
onVideoSubTypeChange={(subType) => {
|
|
if (context.mode !== "node-edit" || !context.node) return;
|
|
const nextConfig = {
|
|
...config,
|
|
subType,
|
|
parameters: filterTaskRoutingParameters(config.parameters),
|
|
};
|
|
setConfig(nextConfig);
|
|
updateNodeData(context.node.id, {
|
|
subType,
|
|
parameters: nextConfig.parameters,
|
|
} as Partial<GenerateVideoNodeData>);
|
|
}}
|
|
promptInputRef={promptInputRef}
|
|
promptValue={prompt}
|
|
promptReadOnly={false}
|
|
promptMinHeightPx={PROMPT_MIN_HEIGHT_PX}
|
|
promptMaxHeightPx={PROMPT_MAX_HEIGHT_PX}
|
|
referenceItems={mentionReferenceItems}
|
|
onDraftPatch={(patch) => {
|
|
const nextPrompt = typeof patch.prompt === "string" ? patch.prompt : prompt;
|
|
if (typeof patch.prompt === "string") {
|
|
setPrompt(patch.prompt);
|
|
}
|
|
if (patch.parameters) {
|
|
const nextParameters = patch.parameters;
|
|
const nextConfig = {
|
|
...config,
|
|
parameters: nextParameters,
|
|
};
|
|
setConfig(nextConfig);
|
|
}
|
|
}}
|
|
onSubmitShortcut={runAction}
|
|
bodyContent={processBody}
|
|
/>
|
|
|
|
{errorMessage && (
|
|
<div className="nowheel mx-2 mb-2 max-h-20 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex shrink-0 items-center gap-3 px-2 py-2 text-sm text-[var(--text-secondary)]">
|
|
{isProcessNode ? (
|
|
<div className="flex min-w-0 items-center gap-2 text-[var(--text-muted)]">
|
|
<span className="truncate">
|
|
{context.node?.type === "easeCurve"
|
|
? ((connectedInputs?.videos.length ?? 0) >= 1 ? t("composer.easeCurveReady") : t("composer.easeCurveNoVideo"))
|
|
: ((connectedInputs?.videos.length ?? 0) >= 2 ? t("composer.videoStitchReady") : t("composer.videoStitchNeedTwo"))}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<ComposerModelConfig
|
|
activeCapability={activeCapability}
|
|
config={config}
|
|
modelSchema={modelSchema}
|
|
extensionFields={extensionFields}
|
|
extraTaskParams={config.extraTaskParams ?? EMPTY_EXTRA_TASK_PARAMS}
|
|
canChooseModel={canChooseModel}
|
|
isProcessNode={false}
|
|
onModelNeedsDetail={onModelNeedsDetail}
|
|
onModelChangeStart={handleModelChangeStart}
|
|
onModelChangeComplete={handleModelChangeComplete}
|
|
onConfigChange={handleConfigChange}
|
|
onExtraTaskParamsChange={handleExtraTaskParamsChange}
|
|
onAspectRatioChange={(value) => {
|
|
if (context.mode === "node-edit" && context.node) onApplyNodeSize(context.node.id, value);
|
|
}}
|
|
getPopupContainer={getComposerModelPopupContainer}
|
|
selectModelPlaceholder={t("composer.selectModel")}
|
|
showLlmModelConfig={false}
|
|
llmModelConfigContent={null}
|
|
llmModelConfigSummary=""
|
|
/>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{priceLabel ? (
|
|
<div
|
|
className="flex h-9 items-center gap-1 text-xs font-medium text-[var(--text-muted)]"
|
|
aria-live="polite"
|
|
title={modelPointCostLabel ?? "Loading points"}
|
|
>
|
|
<svg className="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M13 2 4 14h6l-1 8 10-13h-6l0-7Z" />
|
|
</svg>
|
|
<span>{priceLabel}</span>
|
|
</div>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={runAction}
|
|
disabled={!canClickSubmit}
|
|
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
|
|
aria-label={submitButtonLabel}
|
|
title={submitButtonLabel}
|
|
>
|
|
{isCurrentNodeRunning ? (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.4">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
|
<path d="M12 19V5" />
|
|
<path d="m5 12 7-7 7 7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</ComposerFrame>
|
|
</>
|
|
);
|
|
}
|
|
|