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.
 
 

1803 lines
76 KiB

"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FocusEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import {
AspectRatio,
EaseCurveNodeData,
Generate3DNodeData,
GenerateAudioNodeData,
GenerateVideoNodeData,
ImageInputNodeData,
ImageGenerationCount,
ModelType,
NanoBananaNodeData,
NodeType,
Resolution,
SelectedModel,
VideoStitchNodeData,
WorkflowNode,
WorkflowNodeData,
} from "@/types";
import {
createDefaultAudioModel,
createNewApiWGDefaultImageModel,
createNewApiWGDefaultVideoModel,
createPopiserverDefaultImageModel,
createPopiserverDefaultVideoModel,
} from "@/store/utils/defaultImageModel";
import { isPopiProviderMode } from "@/lib/providerMode";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { ProviderModel } from "@/lib/providers/types";
import { useI18n } from "@/i18n";
import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS } from "@/lib/llmModels";
import {
DEFAULT_VIDEO_DURATION_SECONDS,
DEFAULT_VIDEO_RESOLUTION,
getDefaultVideoSoundEnabled,
modelSupportsVideoSound,
normalizeVideoDurationSeconds,
VIDEO_RESOLUTION_OPTIONS,
} from "@/utils/videoGenerationSettings";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
type ComposerMode = "empty-create" | "node-edit" | "process-node" | "unsupported-node" | "multi-select";
type ComposerCapability = "all" | "image" | "video" | "3d" | "audio";
type EditableNodeType = "nanoBanana" | "generateVideo" | "generateAudio";
type DraftField = "prompt" | "aspectRatio" | "resolution" | "selectedModel" | "inputImages" | "imageCount" | "parameters" | "durationSeconds" | "soundEnabled";
type VideoStitchLoopCount = 1 | 2 | 3;
interface ComposerContext {
mode: ComposerMode;
node: WorkflowNode | null;
capability: ComposerCapability;
nodeType: NodeType | null;
selectedCount: number;
}
interface ComposerDraft {
prompt: string;
aspectRatio: AspectRatio;
resolution: string;
selectedModel: SelectedModel;
inputImages: string[];
imageCount: ImageGenerationCount;
durationSeconds: number;
soundEnabled?: boolean;
parameters?: Record<string, unknown>;
}
interface ComposerAdapter<TData extends WorkflowNodeData> {
nodeType: EditableNodeType;
capability: Exclude<ComposerCapability, "all" | "3d">;
defaultModel: () => SelectedModel;
readDraft: (node: WorkflowNode) => ComposerDraft;
buildPatch: (draft: ComposerDraft, dirtyFields: Set<DraftField>) => Partial<TData>;
}
const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"];
const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"];
const VIDEO_DURATION_QUICK_OPTIONS = [4, 5, 8, 10, 15];
const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4];
const MAX_REFERENCE_IMAGES = 4;
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const COMPOSER_VIEWPORT_MARGIN = 16;
const COMPOSER_EXPANDED_WIDTH = 640;
const COMPOSER_COLLAPSED_WIDTH = 560;
const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300;
const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80;
const DEFAULT_IMAGE_MODEL = isPopiProviderMode()
? createPopiserverDefaultImageModel()
: createNewApiWGDefaultImageModel();
const DEFAULT_VIDEO_MODEL = isPopiProviderMode()
? createPopiserverDefaultVideoModel()
: createNewApiWGDefaultVideoModel();
const DEFAULT_AUDIO_MODEL = createDefaultAudioModel();
const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = {
provider: isPopiProviderMode() ? "popiserver" : "newapiwg",
modelId: DEFAULT_NEWAPIWG_LLM_MODEL_ID,
displayName: (isPopiProviderMode() ? LLM_MODELS.popiserver : LLM_MODELS.newapiwg)
.find((model) => model.value === DEFAULT_NEWAPIWG_LLM_MODEL_ID)?.label || DEFAULT_NEWAPIWG_LLM_MODEL_ID,
capabilities: ["text-to-text"],
};
function createEmptyDraft(): ComposerDraft {
return {
prompt: "",
aspectRatio: "16:9",
resolution: "2K",
selectedModel: DEFAULT_IMAGE_MODEL,
inputImages: [],
imageCount: 1,
durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
soundEnabled: getDefaultVideoSoundEnabled(DEFAULT_VIDEO_MODEL),
parameters: {},
};
}
function buildReferenceOnlyPrompt(): string {
return "基于输入图片生成,保持主体一致、画风一致、构图自然。";
}
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 getComposerModelLabel(model?: SelectedModel): string {
if (!model) return "Select model";
return model.displayName || model.modelId;
}
function getLegacyImageModel(model: SelectedModel): ModelType {
if (
model.provider === "gemini" &&
(model.modelId === "nano-banana" || model.modelId === "nano-banana-pro" || model.modelId === "nano-banana-2")
) {
return model.modelId;
}
return "nano-banana-pro";
}
function selectedModelFromProvider(model: ProviderModel): SelectedModel {
return {
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
pricing: model.pricing
? {
type: model.pricing.type,
amount: model.pricing.amount,
}
: undefined,
metadata: model.metadata,
};
}
function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean {
return Boolean(model.capabilities?.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"]);
return hasAnyCapability(model, ["text-to-audio"]);
}
function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (!model.capabilities || model.capabilities.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";
return null;
}
function inferNodeTypeFromModel(model: SelectedModel): NodeType | null {
const capability = inferCapabilityFromModel(model);
if (capability === "video") return "generateVideo";
if (capability === "3d") return "generate3d";
if (capability === "audio") return "generateAudio";
if (capability === "image") return "nanoBanana";
return null;
}
function hasTextValue(value: string | null | undefined): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function getNodeDisplayTitle(node: WorkflowNode | undefined): string {
if (!node) return "Unknown";
const data = node.data as { customTitle?: unknown; label?: unknown; inputPrompt?: unknown };
if (typeof data.customTitle === "string" && data.customTitle.trim()) return data.customTitle.trim();
if (typeof data.label === "string" && data.label.trim()) return data.label.trim();
if (typeof data.inputPrompt === "string" && data.inputPrompt.trim()) return data.inputPrompt.trim().slice(0, 32);
return node.type;
}
function nodeHasVideoOutput(node: WorkflowNode | undefined): boolean {
if (!node) return false;
const data = node.data as { outputVideo?: unknown; video?: unknown };
return Boolean(data.outputVideo || data.video);
}
function formatBezierHandles(handles: [number, number, number, number]): string {
return handles.map((value) => value.toFixed(2)).join(", ");
}
function buildBezierPreviewPath(handles: [number, number, number, number]): string {
const [x1, y1, x2, y2] = handles;
return `M 8 92 C ${8 + x1 * 84} ${92 - y1 * 84}, ${8 + x2 * 84} ${92 - y2 * 84}, 92 8`;
}
function getNumericSize(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function getComposerNodeDimensions(node: WorkflowNode): { width: number; height: number } {
const defaults = defaultNodeDimensions[node.type as NodeType] ?? { width: 300, height: 220 };
return {
width: node.measured?.width ?? getNumericSize(node.style?.width) ?? defaults.width,
height: node.measured?.height ?? getNumericSize(node.style?.height) ?? defaults.height,
};
}
function readImageFileAsDataUrl(file: File): Promise<string | null> {
if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null);
if (file.size > MAX_REFERENCE_IMAGE_BYTES) return Promise.resolve(null);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (event) => resolve((event.target?.result as string | undefined) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}
const imageComposerAdapter: ComposerAdapter<NanoBananaNodeData> = {
nodeType: "nanoBanana",
capability: "image",
defaultModel: () => DEFAULT_IMAGE_MODEL,
readDraft: (node) => {
const data = node.data as NanoBananaNodeData;
return {
prompt: data.inputPrompt ?? "",
aspectRatio: data.aspectRatio ?? "16:9",
resolution: data.resolution ?? "2K",
selectedModel: data.selectedModel ?? DEFAULT_IMAGE_MODEL,
inputImages: data.inputImages ?? [],
imageCount: data.imageCount ?? 1,
durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
soundEnabled: undefined,
parameters: data.parameters ?? {},
};
},
buildPatch: (draft, dirtyFields) => {
const patch: Partial<NanoBananaNodeData> = {};
if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim();
if (dirtyFields.has("aspectRatio")) patch.aspectRatio = draft.aspectRatio;
if (dirtyFields.has("resolution")) patch.resolution = draft.resolution as Resolution;
if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages;
if (dirtyFields.has("imageCount")) patch.imageCount = draft.imageCount;
if (dirtyFields.has("selectedModel")) {
patch.selectedModel = draft.selectedModel;
patch.model = getLegacyImageModel(draft.selectedModel);
patch.parameters = {};
patch.fallbackParameters = {};
patch.inputSchema = undefined;
}
return patch;
},
};
const videoComposerAdapter: ComposerAdapter<GenerateVideoNodeData> = {
nodeType: "generateVideo",
capability: "video",
defaultModel: () => DEFAULT_VIDEO_MODEL,
readDraft: (node) => {
const data = node.data as GenerateVideoNodeData;
return {
prompt: data.inputPrompt ?? "",
aspectRatio: "16:9",
resolution: data.resolution ?? DEFAULT_VIDEO_RESOLUTION,
selectedModel: data.selectedModel ?? DEFAULT_VIDEO_MODEL,
inputImages: data.inputImages ?? [],
imageCount: 1,
durationSeconds: data.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
soundEnabled: data.soundEnabled ?? getDefaultVideoSoundEnabled(data.selectedModel ?? DEFAULT_VIDEO_MODEL),
parameters: data.parameters ?? {},
};
},
buildPatch: (draft, dirtyFields) => {
const patch: Partial<GenerateVideoNodeData> = {};
if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim();
if (dirtyFields.has("resolution")) patch.resolution = draft.resolution;
if (dirtyFields.has("durationSeconds")) patch.durationSeconds = normalizeVideoDurationSeconds(draft.durationSeconds);
if (dirtyFields.has("soundEnabled")) patch.soundEnabled = draft.soundEnabled;
if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages;
if (dirtyFields.has("selectedModel")) {
patch.selectedModel = draft.selectedModel;
patch.resolution = draft.resolution;
patch.durationSeconds = normalizeVideoDurationSeconds(draft.durationSeconds);
patch.soundEnabled = getDefaultVideoSoundEnabled(draft.selectedModel);
patch.parameters = {};
patch.fallbackParameters = {};
patch.inputSchema = undefined;
}
return patch;
},
};
const audioComposerAdapter: ComposerAdapter<GenerateAudioNodeData> = {
nodeType: "generateAudio",
capability: "audio",
defaultModel: () => DEFAULT_AUDIO_MODEL,
readDraft: (node) => {
const data = node.data as GenerateAudioNodeData;
return {
prompt: data.inputPrompt ?? "",
aspectRatio: "16:9",
resolution: "2K",
selectedModel: data.selectedModel ?? DEFAULT_AUDIO_MODEL,
inputImages: [],
imageCount: 1,
durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
soundEnabled: undefined,
parameters: data.parameters ?? {},
};
},
buildPatch: (draft, dirtyFields) => {
const patch: Partial<GenerateAudioNodeData> = {};
if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim();
if (dirtyFields.has("selectedModel")) {
patch.selectedModel = draft.selectedModel;
patch.parameters = {};
patch.fallbackParameters = {};
patch.inputSchema = undefined;
}
return patch;
},
};
function getAdapter(nodeType: NodeType | null): ComposerAdapter<WorkflowNodeData> | null {
if (!nodeType) return null;
if (nodeType === "nanoBanana") return imageComposerAdapter as ComposerAdapter<WorkflowNodeData>;
if (nodeType === "generateVideo") return videoComposerAdapter as ComposerAdapter<WorkflowNodeData>;
if (nodeType === "generateAudio") return audioComposerAdapter as ComposerAdapter<WorkflowNodeData>;
return null;
}
function deriveComposerContext(nodes: WorkflowNode[]): ComposerContext {
const selectedNodes = nodes.filter((node) => node.selected);
if (selectedNodes.length === 0) {
return { mode: "empty-create", node: null, capability: "all", nodeType: null, selectedCount: 0 };
}
if (selectedNodes.length > 1) {
return { mode: "multi-select", node: null, capability: "all", nodeType: null, selectedCount: selectedNodes.length };
}
const node = selectedNodes[0];
const adapter = getAdapter(node.type);
if (node.type === "videoStitch" || node.type === "easeCurve") {
return { mode: "process-node", node, capability: "video", nodeType: node.type, selectedCount: 1 };
}
if (!adapter) {
return { mode: "unsupported-node", node, capability: "all", nodeType: node.type, selectedCount: 1 };
}
return {
mode: "node-edit",
node,
capability: adapter.capability,
nodeType: adapter.nodeType,
selectedCount: 1,
};
}
function contextIdentity(context: ComposerContext): string {
return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}`;
}
function readDraftFromContext(context: ComposerContext): ComposerDraft {
if (context.mode === "unsupported-node" && context.nodeType === "prompt" && context.node) {
return createPromptDraft(context.node);
}
if (context.mode !== "node-edit") return createEmptyDraft();
const adapter = getAdapter(context.nodeType);
return adapter && context.node ? adapter.readDraft(context.node) : createEmptyDraft();
}
function nodeDraftFingerprint(context: ComposerContext): string {
if (context.mode !== "node-edit" || !context.node) return contextIdentity(context);
const data = context.node.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData;
return JSON.stringify({
identity: contextIdentity(context),
inputPrompt: data.inputPrompt,
inputImages: "inputImages" in data ? data.inputImages : undefined,
aspectRatio: "aspectRatio" in data ? data.aspectRatio : undefined,
resolution: "resolution" in data ? data.resolution : undefined,
durationSeconds: "durationSeconds" in data ? data.durationSeconds : undefined,
soundEnabled: "soundEnabled" in data ? data.soundEnabled : undefined,
imageCount: "imageCount" in data ? data.imageCount : undefined,
selectedModel: data.selectedModel
? {
provider: data.selectedModel.provider,
modelId: data.selectedModel.modelId,
displayName: data.selectedModel.displayName,
capabilities: data.selectedModel.capabilities,
}
: null,
parameters: data.parameters,
});
}
function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial<WorkflowNodeData> {
if (nodeType === "nanoBanana") {
return {
inputImages: draft.inputImages,
inputPrompt: draft.prompt.trim(),
aspectRatio: draft.aspectRatio,
resolution: draft.resolution as Resolution,
model: getLegacyImageModel(draft.selectedModel),
selectedModel: draft.selectedModel,
imageCount: draft.imageCount,
parameters: {},
inputSchema: undefined,
useGoogleSearch: false,
useImageSearch: false,
status: "idle",
error: null,
} satisfies Partial<NanoBananaNodeData>;
}
if (nodeType === "generateVideo") {
return {
inputImages: draft.inputImages,
inputPrompt: draft.prompt.trim(),
selectedModel: draft.selectedModel,
resolution: draft.resolution,
durationSeconds: normalizeVideoDurationSeconds(draft.durationSeconds),
soundEnabled: modelSupportsVideoSound(draft.selectedModel) ? draft.soundEnabled ?? true : undefined,
parameters: {},
inputSchema: undefined,
status: "idle",
error: null,
} satisfies Partial<GenerateVideoNodeData>;
}
if (nodeType === "generate3d") {
return {
inputImages: draft.inputImages,
inputPrompt: draft.prompt.trim(),
selectedModel: draft.selectedModel,
parameters: {},
inputSchema: undefined,
status: "idle",
error: null,
} satisfies Partial<Generate3DNodeData>;
}
if (nodeType === "generateAudio") {
return {
inputPrompt: draft.prompt.trim(),
selectedModel: draft.selectedModel,
parameters: {},
inputSchema: undefined,
status: "idle",
error: null,
} satisfies Partial<GenerateAudioNodeData>;
}
return {};
}
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-neutral-400 transition-colors hover:bg-neutral-700/80 hover:text-neutral-100"
>
{children}
</button>
);
}
export function GenerationComposer() {
const { t } = useI18n();
const nodes = useWorkflowStore((state) => state.nodes);
const edges = useWorkflowStore((state) => state.edges);
const dimmedNodeIds = useWorkflowStore((state) => state.dimmedNodeIds);
const isRunning = useWorkflowStore((state) => state.isRunning);
const isModalOpen = useWorkflowStore((state) => state.isModalOpen);
const addNode = useWorkflowStore((state) => state.addNode);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const { screenToFlowPosition } = useReactFlow();
const { x: viewportX, y: viewportY, zoom } = useViewport();
const context = useMemo(() => deriveComposerContext(nodes), [nodes]);
const identity = contextIdentity(context);
const fingerprint = nodeDraftFingerprint(context);
const [draft, setDraft] = useState(() => readDraftFromContext(context));
const [dirtyFields, setDirtyFields] = useState<Set<DraftField>>(() => new Set());
const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null);
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
const [referencePreviewIndex, setReferencePreviewIndex] = useState<number | null>(null);
const referenceImageInputRef = useRef<HTMLInputElement>(null);
const contextRef = useRef(context);
const draftRef = useRef(draft);
const dirtyFieldsRef = useRef(dirtyFields);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
dirtyFieldsRef.current = dirtyFields;
}, [dirtyFields]);
const clearDirtyFields = useCallback(() => {
const empty = new Set<DraftField>();
dirtyFieldsRef.current = empty;
setDirtyFields(empty);
}, []);
const flushDraftForContext = useCallback(
(targetContext: ComposerContext) => {
const targetAdapter = getAdapter(targetContext.nodeType);
const dirty = dirtyFieldsRef.current;
if (targetContext.mode !== "node-edit" || !targetContext.node || !targetAdapter || dirty.size === 0) {
return;
}
const nodeStillExists = useWorkflowStore.getState().nodes.some((node) => node.id === targetContext.node?.id);
if (!nodeStillExists) {
clearDirtyFields();
return;
}
const patch = targetAdapter.buildPatch(draftRef.current, dirty);
if (Object.keys(patch).length > 0) {
updateNodeData(targetContext.node.id, patch);
}
clearDirtyFields();
},
[clearDirtyFields, updateNodeData]
);
const flushCurrentDraft = useCallback(() => {
flushDraftForContext(contextRef.current);
}, [flushDraftForContext]);
useEffect(() => {
const previousContext = contextRef.current;
if (contextIdentity(previousContext) === identity) return;
flushDraftForContext(previousContext);
contextRef.current = context;
setDraft(readDraftFromContext(context));
clearDirtyFields();
}, [clearDirtyFields, context, flushDraftForContext, identity]);
useEffect(() => {
if (context.mode !== "node-edit") return;
const nextDraft = readDraftFromContext(context);
setDraft((current) => {
const dirty = dirtyFieldsRef.current;
return {
prompt: dirty.has("prompt") ? current.prompt : nextDraft.prompt,
aspectRatio: dirty.has("aspectRatio") ? current.aspectRatio : nextDraft.aspectRatio,
resolution: dirty.has("resolution") ? current.resolution : nextDraft.resolution,
selectedModel: dirty.has("selectedModel") ? current.selectedModel : nextDraft.selectedModel,
inputImages: dirty.has("inputImages") ? current.inputImages : nextDraft.inputImages,
imageCount: dirty.has("imageCount") ? current.imageCount : nextDraft.imageCount,
durationSeconds: dirty.has("durationSeconds") ? current.durationSeconds : nextDraft.durationSeconds,
soundEnabled: dirty.has("soundEnabled") ? current.soundEnabled : nextDraft.soundEnabled,
parameters: dirty.has("parameters") ? current.parameters : nextDraft.parameters,
};
});
}, [context, fingerprint]);
const connectedInputs = useMemo(() => {
if ((context.mode !== "node-edit" && context.mode !== "process-node") || !context.node) return null;
return getConnectedInputs(context.node.id);
}, [context, getConnectedInputs, nodes, edges, dimmedNodeIds]);
const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch";
const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve";
const isProcessNode = isVideoStitchNode || isEaseCurveNode;
const videoStitchData = isVideoStitchNode && context.node ? (context.node.data as VideoStitchNodeData) : null;
const easeCurveData = isEaseCurveNode && context.node ? (context.node.data as EaseCurveNodeData) : null;
const processVideoInput = useMemo(() => {
if (!isEaseCurveNode || !context.node) return null;
const videoEdge = edges.find((edge) => edge.target === context.node?.id && edge.targetHandle === "video");
const sourceNode = videoEdge ? nodes.find((node) => node.id === videoEdge.source) : undefined;
return videoEdge
? {
edgeId: videoEdge.id,
title: getNodeDisplayTitle(sourceNode),
hasVideo: nodeHasVideoOutput(sourceNode),
}
: null;
}, [context.node, edges, isEaseCurveNode, nodes]);
const videoStitchClipSummaries = useMemo(() => {
if (!isVideoStitchNode || !context.node) return [];
const stitchData = context.node.data as VideoStitchNodeData;
const stitchEdges = edges.filter(
(edge) => edge.target === context.node?.id && edge.targetHandle?.startsWith("video-")
);
const clipOrder = stitchData.clipOrder ?? [];
const orderedEdges = [...stitchEdges].sort((a, b) => {
const orderA = clipOrder.indexOf(a.id);
const orderB = clipOrder.indexOf(b.id);
if (orderA !== -1 && orderB !== -1) return orderA - orderB;
if (orderA !== -1) return -1;
if (orderB !== -1) return 1;
return ((a.data as { createdAt?: number } | undefined)?.createdAt ?? 0) -
((b.data as { createdAt?: number } | undefined)?.createdAt ?? 0);
});
return orderedEdges.map((edge, index) => {
const sourceNode = nodes.find((node) => node.id === edge.source);
return {
edgeId: edge.id,
index: index + 1,
title: getNodeDisplayTitle(sourceNode),
handle: edge.targetHandle ?? `video-${index}`,
hasVideo: nodeHasVideoOutput(sourceNode),
};
});
}, [context.node, edges, isVideoStitchNode, nodes]);
const connectedPrompt = hasTextValue(connectedInputs?.text) ? connectedInputs.text : null;
const promptValue = connectedPrompt ?? draft.prompt;
const promptReadOnly = Boolean(connectedPrompt);
const activeCapability = context.mode === "empty-create"
? inferCapabilityFromModel(draft.selectedModel) ?? "image"
: context.capability;
const videoSoundSupported = activeCapability === "video" && modelSupportsVideoSound(draft.selectedModel);
const videoDurationOptions = useMemo(() => {
const duration = normalizeVideoDurationSeconds(draft.durationSeconds);
return VIDEO_DURATION_QUICK_OPTIONS.includes(duration)
? VIDEO_DURATION_QUICK_OPTIONS
: [...VIDEO_DURATION_QUICK_OPTIONS, duration].sort((a, b) => a - b);
}, [draft.durationSeconds]);
const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit";
const modelIsValid = context.mode === "empty-create"
? Boolean(inferNodeTypeFromModel(draft.selectedModel))
: storedModelFitsCapability(draft.selectedModel, context.capability);
const promptForSubmit = promptValue.trim();
const selectedImageInputImage = context.node?.type === "imageInput"
? (context.node.data as ImageInputNodeData).image
: null;
const hasReferenceImageForSubmit =
(context.mode === "empty-create" || context.mode === "node-edit") &&
((connectedInputs?.images.length ?? 0) > 0 || draft.inputImages.length > 0);
const submitNodeType = context.mode === "empty-create"
? inferNodeTypeFromModel(draft.selectedModel)
: context.nodeType;
const hasRequiredPromptOrReference = submitNodeType === "generateAudio"
? promptForSubmit.length > 0
: (promptForSubmit.length > 0 || hasReferenceImageForSubmit);
const canCreateFromSelectedImageInput =
context.mode === "unsupported-node" &&
Boolean(selectedImageInputImage) &&
!isRunning &&
modelSupportsCapability(draft.selectedModel, "image");
const canRunVideoStitch =
isVideoStitchNode &&
!isRunning &&
videoStitchData?.encoderSupported !== false &&
(connectedInputs?.videos.length ?? 0) >= 2;
const canRunEaseCurve =
isEaseCurveNode &&
!isRunning &&
easeCurveData?.encoderSupported !== false &&
(connectedInputs?.videos.length ?? 0) >= 1;
const canSubmit =
canRunVideoStitch ||
canRunEaseCurve ||
(
!isRunning &&
(context.mode === "empty-create" || context.mode === "node-edit") &&
hasRequiredPromptOrReference &&
modelIsValid
) ||
canCreateFromSelectedImageInput;
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages;
const primaryReference = referenceImages[0];
const previewReferenceImage =
referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null;
const canEditReferenceImages =
(context.mode === "empty-create" || context.mode === "node-edit") &&
connectedReferenceImages.length === 0;
const nodeData = context.node?.data as (NanoBananaNodeData | GenerateVideoNodeData | VideoStitchNodeData | EaseCurveNodeData | undefined);
const errorMessage = nodeData?.status === "error" ? nodeData.error : null;
const nodeEditModeLabel =
context.nodeType === "nanoBanana"
? t("composer.imageNode")
: context.nodeType === "generateVideo"
? t("composer.videoNode")
: context.nodeType === "generateAudio"
? t("composer.audioNode")
: t("composer.currentNode");
const nodeEditGenerateTitle =
context.nodeType === "nanoBanana"
? t("composer.regenerateImageNode")
: context.nodeType === "generateVideo"
? t("composer.regenerateVideoNode")
: context.nodeType === "generateAudio"
? t("composer.regenerateAudioNode")
: t("composer.generate");
const modelDialogTitle =
context.nodeType === "nanoBanana"
? t("composer.chooseImageModel")
: context.nodeType === "generateVideo"
? t("composer.chooseVideoModel")
: context.nodeType === "generateAudio"
? t("composer.chooseAudioModel")
: t("composer.chooseModelTitle");
const modeLabel = context.mode === "empty-create"
? t("composer.newGeneration")
: context.mode === "multi-select"
? t("composer.selectedNodes", { count: context.selectedCount })
: context.mode === "unsupported-node"
? t("composer.currentNode")
: context.mode === "process-node"
? t(context.nodeType === "easeCurve" ? "node.easeCurve" : "node.videoStitch")
: nodeEditModeLabel;
const generateTitle = context.mode === "empty-create"
? t("composer.createAndGenerate")
: context.mode === "unsupported-node"
? t("composer.dragFromPort")
: context.mode === "multi-select"
? t("composer.multiSelectNoSingleGenerate")
: isVideoStitchNode
? t("composer.videoStitchRun")
: isEaseCurveNode
? t("composer.easeCurveRun")
: errorMessage
? t("composer.retryCurrentNode")
: nodeEditGenerateTitle;
const selectedNodeDimensions = useMemo(
() => context.node ? getComposerNodeDimensions(context.node) : null,
[context.node]
);
const safeZoom = zoom > 0 ? zoom : 1;
const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth;
const viewportHeight = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerHeight;
const visibleLeft = -viewportX / safeZoom;
const visibleRight = (viewportWidth - viewportX) / safeZoom;
const visibleTop = -viewportY / safeZoom;
const visibleBottom = (viewportHeight - viewportY) / safeZoom;
const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH;
const composerFlowWidth = composerWidth / safeZoom;
const composerHeightEstimate = isComposerCollapsed
? COMPOSER_COLLAPSED_HEIGHT_ESTIMATE
: COMPOSER_EXPANDED_HEIGHT_ESTIMATE;
const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom;
const inlineComposerPosition = context.node && selectedNodeDimensions
? (() => {
const centeredLeft = context.node.position.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2;
const minLeft = visibleLeft + viewportMargin;
const maxLeft = visibleRight - composerFlowWidth - viewportMargin;
const left = maxLeft >= minLeft
? Math.min(Math.max(centeredLeft, minLeft), maxLeft)
: minLeft;
const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin;
const aboveTop = context.node.position.y - composerHeightEstimate / safeZoom - viewportMargin;
const belowFits = belowTop + composerHeightEstimate / safeZoom <= visibleBottom - viewportMargin;
const aboveFits = aboveTop >= visibleTop + viewportMargin;
return {
left,
top: belowFits || !aboveFits ? belowTop : aboveTop,
};
})()
: null;
const shouldRenderInlineComposer =
(context.mode === "node-edit" || context.mode === "process-node") &&
Boolean(context.node && inlineComposerPosition);
const markDraft = useCallback((patch: Partial<ComposerDraft>, fields: DraftField[]) => {
setDraft((current) => {
const next = { ...current, ...patch };
draftRef.current = next;
return next;
});
setDirtyFields((current) => {
const next = new Set(current);
fields.forEach((field) => next.add(field));
dirtyFieldsRef.current = next;
return next;
});
}, []);
const updateVideoQuickDraft = useCallback(
(patch: Partial<ComposerDraft>, fields: DraftField[]) => {
const activeContext = contextRef.current;
setDraft((current) => {
const next = { ...current, ...patch };
draftRef.current = next;
return next;
});
if (activeContext.mode === "node-edit" && activeContext.node?.type === "generateVideo") {
const nodePatch: Partial<GenerateVideoNodeData> = {};
if (fields.includes("durationSeconds") && patch.durationSeconds !== undefined) {
nodePatch.durationSeconds = normalizeVideoDurationSeconds(patch.durationSeconds);
}
if (fields.includes("resolution") && patch.resolution !== undefined) {
nodePatch.resolution = patch.resolution;
}
if (fields.includes("soundEnabled") && Object.prototype.hasOwnProperty.call(patch, "soundEnabled")) {
nodePatch.soundEnabled = patch.soundEnabled;
}
if (Object.keys(nodePatch).length > 0) {
updateNodeData(activeContext.node.id, nodePatch);
}
setDirtyFields((current) => {
const next = new Set(current);
fields.forEach((field) => next.delete(field));
dirtyFieldsRef.current = next;
return next;
});
return;
}
setDirtyFields((current) => {
const next = new Set(current);
fields.forEach((field) => next.add(field));
dirtyFieldsRef.current = next;
return next;
});
},
[updateNodeData]
);
const handleComposerBlur = useCallback(
(event: FocusEvent<HTMLElement>) => {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && event.currentTarget.contains(nextTarget)) return;
flushCurrentDraft();
},
[flushCurrentDraft]
);
const stopCanvasEvent = useCallback((event: { stopPropagation: () => void; target?: EventTarget | null }) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-composer-local-pointer='true']")) return;
event.stopPropagation();
}, []);
const buildTargetPosition = useCallback(() => {
return screenToFlowPosition({
x: window.innerWidth / 2 - 150,
y: window.innerHeight / 2 - 140,
});
}, [screenToFlowPosition]);
const handleModelSelected = useCallback(
(model: ProviderModel) => {
const nextModel = selectedModelFromProvider(model);
const activeContext = contextRef.current;
if (activeContext.mode === "node-edit") {
const activeAdapter = getAdapter(activeContext.nodeType);
if (!activeContext.node || !activeAdapter || !modelSupportsCapability(nextModel, activeAdapter.capability)) {
return;
}
const modelPatch = activeAdapter.buildPatch(
{
...draftRef.current,
selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : draftRef.current.resolution,
durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS
: draftRef.current.durationSeconds,
soundEnabled: getDefaultVideoSoundEnabled(nextModel),
},
new Set<DraftField>(["selectedModel"])
);
updateNodeData(activeContext.node.id, modelPatch);
setDraft((current) => ({
...current,
selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution,
durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS
: current.durationSeconds,
soundEnabled: getDefaultVideoSoundEnabled(nextModel),
parameters: {},
}));
clearDirtyFields();
return;
}
if (activeContext.mode === "empty-create") {
setDraft((current) => ({
...current,
selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution,
durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS
: current.durationSeconds,
soundEnabled: getDefaultVideoSoundEnabled(nextModel),
parameters: {},
}));
}
},
[clearDirtyFields, updateNodeData]
);
const handleReferenceImageChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
if (!canEditReferenceImages) return;
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0) return;
const loadedImages = (await Promise.all(files.map(readImageFileAsDataUrl))).filter(
(image): image is string => Boolean(image)
);
if (loadedImages.length === 0) return;
markDraft(
{
inputImages: [...draftRef.current.inputImages, ...loadedImages].slice(0, MAX_REFERENCE_IMAGES),
},
["inputImages"]
);
},
[canEditReferenceImages, markDraft]
);
const removeReferenceImage = useCallback(
(index: number) => {
if (!canEditReferenceImages) return;
markDraft(
{
inputImages: draftRef.current.inputImages.filter((_, imageIndex) => imageIndex !== index),
},
["inputImages"]
);
},
[canEditReferenceImages, markDraft]
);
useEffect(() => {
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) {
setReferencePreviewIndex(null);
}
}, [referenceImages.length, referencePreviewIndex]);
useEffect(() => {
if (!previewReferenceImage) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setReferencePreviewIndex(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewReferenceImage]);
useEffect(() => {
if (promptReadOnly) {
setAssistMenu(null);
return;
}
const lastChar = draft.prompt.at(-1);
if (lastChar === "/") {
setAssistMenu("command");
} else if (lastChar === "@") {
setAssistMenu("reference");
} else if (!draft.prompt.includes("/") && !draft.prompt.includes("@")) {
setAssistMenu(null);
}
}, [draft.prompt, promptReadOnly]);
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
const fallbackPrompt = buildReferenceOnlyPrompt();
const submitPrompt = promptForSubmit || fallbackPrompt;
if (context.mode === "unsupported-node" && context.node?.type === "imageInput") {
const imageData = context.node.data as ImageInputNodeData;
const sourceImage = imageData.image;
if (!sourceImage) return;
const selectedModel = modelSupportsCapability(draftRef.current.selectedModel, "image")
? draftRef.current.selectedModel
: DEFAULT_IMAGE_MODEL;
const nextNodeId = addNode(
"nanoBanana",
buildTargetPosition(),
buildInitialDataForNode("nanoBanana", {
...draftRef.current,
selectedModel,
inputImages: [sourceImage],
prompt: submitPrompt,
})
);
selectSingleNode(nextNodeId);
setDraft(createEmptyDraft());
clearDirtyFields();
await regenerateNode(nextNodeId);
return;
}
if (context.mode === "empty-create") {
const nodeType = inferNodeTypeFromModel(draftRef.current.selectedModel);
if (!nodeType) return;
const nextNodeId = addNode(
nodeType,
buildTargetPosition(),
buildInitialDataForNode(nodeType, {
...draftRef.current,
prompt: submitPrompt,
})
);
selectSingleNode(nextNodeId);
setDraft(createEmptyDraft());
clearDirtyFields();
await regenerateNode(nextNodeId);
return;
}
if (context.mode === "node-edit" && context.node) {
if (!promptForSubmit && !promptReadOnly) {
const nextDraft = { ...draftRef.current, prompt: submitPrompt };
const nextDirtyFields = new Set(dirtyFieldsRef.current);
nextDirtyFields.add("prompt");
draftRef.current = nextDraft;
dirtyFieldsRef.current = nextDirtyFields;
setDraft(nextDraft);
setDirtyFields(nextDirtyFields);
}
flushCurrentDraft();
await regenerateNode(context.node.id);
return;
}
if (
context.mode === "process-node" &&
(context.node?.type === "videoStitch" || context.node?.type === "easeCurve")
) {
await regenerateNode(context.node.id);
}
}, [
addNode,
buildTargetPosition,
canSubmit,
clearDirtyFields,
context,
flushCurrentDraft,
promptForSubmit,
promptReadOnly,
regenerateNode,
selectSingleNode,
]);
const insertCommand = useCallback((value: string) => {
markDraft({ prompt: draftRef.current.prompt.replace(/\/$/, "") + value }, ["prompt"]);
setAssistMenu(null);
}, [markDraft]);
const insertReference = useCallback((value: string) => {
markDraft({ prompt: draftRef.current.prompt.replace(/@$/, "") + value }, ["prompt"]);
setAssistMenu(null);
}, [markDraft]);
const toggleComposerCollapsed = useCallback(() => {
if (!isComposerCollapsed) {
flushCurrentDraft();
setAssistMenu(null);
}
setIsComposerCollapsed((current) => !current);
}, [flushCurrentDraft, isComposerCollapsed]);
const expandComposer = useCallback(() => {
setIsComposerCollapsed(false);
}, []);
const handleCollapsedComposerClick = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("button,a,input,textarea,select,[role='button']")) return;
expandComposer();
},
[expandComposer]
);
const collapsedStatus = useMemo(() => {
if (isEaseCurveNode) {
return (connectedInputs?.videos.length ?? 0) >= 1
? t("composer.easeCurveReady")
: t("composer.easeCurveNoVideo");
}
if (isVideoStitchNode) {
return (connectedInputs?.videos.length ?? 0) >= 2
? t("composer.videoStitchReady")
: t("composer.videoStitchNeedTwo");
}
if (promptForSubmit) return promptForSubmit;
if (context.mode === "unsupported-node") return t("composer.dragFromPort");
if (context.mode === "multi-select") return t("composer.selectedNodes", { count: context.selectedCount });
return t("composer.describeContent");
}, [connectedInputs, context.mode, context.selectedCount, isEaseCurveNode, isVideoStitchNode, promptForSubmit, t]);
const hideComposerForModal = isModalOpen && !modelDialogOpen;
return (
<>
{!hideComposerForModal && shouldRenderInlineComposer && inlineComposerPosition && (
<ViewportPortal>
<div
data-testid="node-inline-composer"
className="pointer-events-none absolute"
style={{
left: `${inlineComposerPosition.left}px`,
top: `${inlineComposerPosition.top}px`,
zIndex: 10020,
}}
>
<div
className="pointer-events-none"
style={{
transform: `scale(${1 / safeZoom})`,
transformOrigin: "top left",
}}
>
<section
data-testid="node-inline-composer-panel"
className="nodrag nopan nowheel pointer-events-auto max-w-[calc(100vw-2rem)] rounded-2xl border border-neutral-700/80 bg-neutral-800/95 shadow-2xl shadow-black/35 backdrop-blur transition-[width] duration-200"
style={{ width: `${composerWidth}px` }}
onBlur={handleComposerBlur}
onPointerDownCapture={stopCanvasEvent}
onMouseDownCapture={stopCanvasEvent}
onClick={stopCanvasEvent}
onWheelCapture={stopCanvasEvent}
>
<input
ref={referenceImageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
aria-label={t("composer.uploadReferenceImage")}
onChange={handleReferenceImageChange}
className="hidden"
/>
{isComposerCollapsed ? (
<div className="flex h-16 cursor-pointer items-center gap-3 px-4" onClick={handleCollapsedComposerClick}>
<div className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={expandComposer}
title={t("composer.expand")}
className="shrink-0 rounded text-left text-[11px] text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
>
{modeLabel}
</button>
<span className="h-3 w-px shrink-0 bg-neutral-700" />
{isProcessNode ? (
<button
type="button"
onClick={expandComposer}
title={t("composer.expand")}
className="min-w-0 truncate rounded text-left text-sm text-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
>
{collapsedStatus}
</button>
) : (
<button
type="button"
onClick={() => canChooseModel && setModelDialogOpen(true)}
disabled={!canChooseModel}
aria-label={t("composer.selectModel")}
className="min-w-0 truncate rounded-md text-left text-sm text-neutral-100 transition-colors hover:text-cyan-200 disabled:text-neutral-500"
title={t("composer.selectModelTitle", { model: getComposerModelLabel(draft.selectedModel) })}
>
{getComposerModelLabel(draft.selectedModel)}
</button>
)}
</span>
{!isProcessNode && (
<button
type="button"
onClick={expandComposer}
title={t("composer.expand")}
className="mt-1 block max-w-full truncate rounded text-left text-xs text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
>
{collapsedStatus}
</button>
)}
</div>
<button
type="button"
onClick={expandComposer}
aria-label={t("composer.expand")}
title={t("composer.expand")}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-900/70 text-neutral-300 transition-colors hover:border-neutral-600 hover:text-neutral-100"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M7 17 17 7" />
<path d="M8 7h9v9" />
</svg>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 transition-colors hover:bg-white disabled:cursor-not-allowed disabled:bg-neutral-600 disabled:text-neutral-400"
aria-label={t("composer.generate")}
title={generateTitle}
>
{isRunning ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-500 border-t-transparent" />
) : (
<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 className="flex items-center gap-2 px-4 pt-4">
<div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3">
<span className="text-[10px] text-neutral-500">{t("composer.console")}</span>
<span className="text-xs font-medium text-neutral-100">{modeLabel}</span>
</div>
{isProcessNode ? (
isVideoStitchNode ? (
<div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">
<span>
{t("composer.videoStitchClipCount", { count: connectedInputs?.videos.length ?? 0 })}
</span>
<span className="h-4 w-px bg-neutral-700" />
<span className={connectedInputs?.audio.length ? "text-violet-200" : "text-neutral-500"}>
{connectedInputs?.audio.length ? t("composer.videoStitchAudio") : t("composer.videoStitchNoAudio")}
</span>
</div>
) : (
<div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">
<span>
{connectedInputs?.videos.length ? t("composer.easeCurveVideoConnected") : t("composer.easeCurveNoVideo")}
</span>
<span className="h-4 w-px bg-neutral-700" />
<span className={connectedInputs?.easeCurve ? "text-lime-200" : "text-neutral-500"}>
{connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")}
</span>
</div>
)
) : (
<>
<button
type="button"
onClick={() => referenceImageInputRef.current?.click()}
disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES}
title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")}
className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-base leading-none">+</span>
<span>{t("composer.reference")}</span>
</button>
{referenceImages.length > 0 && (
<div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
{referenceImages.map((referenceImage, imageIndex) => (
<div
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
>
<button
type="button"
aria-label={t("composer.referenceImage", { index: imageIndex + 1 })}
title={t("composer.referenceImage", { index: imageIndex + 1 })}
onClick={() => setReferencePreviewIndex(imageIndex)}
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<img
src={referenceImage}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
<svg
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 3h6v6" />
<path d="M21 3l-7 7" />
<path d="M9 21H3v-6" />
<path d="M3 21l7-7" />
</svg>
</span>
</button>
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
{imageIndex + 1}
</span>
{canEditReferenceImages && (
<button
type="button"
aria-label={t("composer.removeReferenceImage", { index: imageIndex + 1 })}
onClick={(event) => {
event.stopPropagation();
removeReferenceImage(imageIndex);
}}
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white"
>
×
</button>
)}
</div>
))}
</div>
)}
</>
)}
<div className="ml-auto flex items-center gap-1 text-neutral-500">
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 7 7 17" />
<path d="M16 17H7V8" />
</svg>
</IconButton>
</div>
</div>
<div className="relative px-4 pt-3">
{assistMenu && (
<div className="absolute bottom-full left-4 mb-2 w-72 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800 shadow-2xl shadow-black/40">
<div className="border-b border-neutral-700 px-3 py-2 text-[11px] font-medium text-neutral-500">
{assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")}
</div>
{assistMenu === "command" ? (
<div className="py-1">
{[
{ label: t("composer.commandCharacter"), value: t("composer.commandCharacterValue") },
{ label: t("composer.commandTurnaround"), value: t("composer.commandTurnaroundValue") },
{ label: t("composer.commandVideoShot"), value: t("composer.commandVideoShotValue") },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => insertCommand(item.value)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<span>{item.label}</span>
<span className="text-neutral-500">/</span>
</button>
))}
</div>
) : (
<div className="py-1">
{primaryReference ? (
<button
type="button"
onClick={() => insertReference(`@${t("composer.currentReference")} `)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<img src={primaryReference} alt="" className="h-8 w-8 rounded object-cover" />
<span>{t("composer.currentReference")}</span>
</button>
) : (
<button
type="button"
onClick={() => insertReference(`@${t("composer.canvasAssets")} `)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<span>{t("composer.canvasAssets")}</span>
<span className="text-neutral-500">@</span>
</button>
)}
</div>
)}
</div>
)}
{isVideoStitchNode ? (
<div className="grid gap-3 md:grid-cols-[1fr_180px]">
<div className="min-w-0 rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-neutral-200">
{t("composer.videoStitchInputs")}
</span>
<span className="text-[11px] text-neutral-500">
{t("composer.videoStitchInputHint")}
</span>
</div>
{videoStitchClipSummaries.length === 0 ? (
<div className="mt-3 rounded-md border border-dashed border-neutral-700 px-3 py-5 text-center text-xs text-neutral-500">
{t("composer.videoStitchNeedTwo")}
</div>
) : (
<div className="nowheel mt-3 grid max-h-28 grid-cols-2 gap-2 overflow-y-auto pr-1">
{videoStitchClipSummaries.map((clip) => (
<div
key={clip.edgeId}
className="flex min-w-0 items-center gap-2 rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${clip.hasVideo ? "bg-pink-400" : "bg-neutral-600"}`} />
<div className="min-w-0">
<div className="truncate text-xs text-neutral-200">
{t("composer.videoStitchClip", { index: clip.index })}
</div>
<div className="truncate text-[11px] text-neutral-500">
{clip.title} · {clip.handle}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
<div className="text-xs font-medium text-neutral-200">
{t("composer.videoStitchLoop")}
</div>
<div className="mt-2 grid grid-cols-3 gap-1">
{([1, 2, 3] as VideoStitchLoopCount[]).map((count) => (
<button
key={count}
type="button"
onClick={() => context.node && updateNodeData(context.node.id, { loopCount: count })}
className={`rounded-md px-2 py-1.5 text-xs transition-colors ${
videoStitchData?.loopCount === count
? "bg-blue-600 text-white"
: "bg-neutral-800 text-neutral-400 hover:bg-neutral-700 hover:text-neutral-100"
}`}
>
{count}x
</button>
))}
</div>
<div className="mt-3 rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2 text-[11px] text-neutral-400">
{videoStitchData?.outputVideo
? t("composer.videoStitchOutputReady")
: t("composer.videoStitchNoOutput")}
</div>
</div>
</div>
) : isEaseCurveNode ? (
<div className="grid gap-3 md:grid-cols-[1fr_180px]">
<div className="min-w-0 rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-neutral-200">
{t("composer.easeCurveSettings")}
</span>
<span className="text-[11px] text-neutral-500">
{connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")}
</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-[120px_1fr]">
<div className="rounded-md border border-neutral-700 bg-neutral-950/60 p-2">
<svg viewBox="0 0 100 100" className="h-24 w-full">
<path d="M 8 92 L 92 8" stroke="rgb(64 64 64)" strokeWidth="2" strokeDasharray="5 5" fill="none" />
<path
d={buildBezierPreviewPath(easeCurveData?.bezierHandles ?? [0.42, 0, 0.58, 1])}
stroke="rgb(190 242 100)"
strokeWidth="4"
fill="none"
strokeLinecap="round"
/>
</svg>
</div>
<div className="min-w-0 space-y-2">
<div className="rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2">
<div className="text-[11px] text-neutral-500">{t("composer.easeCurvePreset")}</div>
<div className="truncate text-xs text-neutral-200">
{easeCurveData?.easingPreset ?? t("composer.easeCurveCustom")}
</div>
</div>
<div className="rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2">
<div className="text-[11px] text-neutral-500">{t("composer.easeCurveHandles")}</div>
<div className="truncate font-mono text-[11px] text-neutral-300">
{formatBezierHandles(easeCurveData?.bezierHandles ?? [0.42, 0, 0.58, 1])}
</div>
</div>
<div className={`rounded-md border px-2 py-2 text-[11px] ${
processVideoInput?.hasVideo
? "border-pink-500/30 bg-pink-500/10 text-pink-100"
: "border-neutral-700 bg-neutral-800/70 text-neutral-500"
}`}>
{processVideoInput?.hasVideo
? `${t("composer.easeCurveInput")}: ${processVideoInput.title}`
: t("composer.easeCurveNoVideo")}
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
<label className="text-xs font-medium text-neutral-200" htmlFor="composer-ease-duration">
{t("composer.easeCurveDuration")}
</label>
<input
id="composer-ease-duration"
type="number"
min="0.1"
max="30"
step="0.1"
value={easeCurveData?.outputDuration ?? 1.5}
onChange={(event) => {
if (!context.node) return;
const value = Number.parseFloat(event.target.value);
updateNodeData(context.node.id, {
outputDuration: Number.isFinite(value) ? Math.max(0.1, Math.min(30, value)) : 1.5,
});
}}
className="mt-2 w-full rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1.5 text-xs text-neutral-100 outline-none focus:border-blue-500"
/>
<div className="mt-3 rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2 text-[11px] text-neutral-400">
{easeCurveData?.outputVideo
? t("composer.easeCurveOutputReady")
: t("composer.easeCurveNoOutput")}
</div>
</div>
</div>
) : context.mode === "unsupported-node" || context.mode === "multi-select" ? (
<div className="flex h-24 items-center rounded-lg text-sm text-neutral-400">
{context.mode === "multi-select"
? t("composer.multiSelectUnsupported", { count: context.selectedCount })
: t("composer.unsupportedNodeHint")}
</div>
) : (
<>
<textarea
value={promptValue}
readOnly={promptReadOnly}
onChange={(event) => markDraft({ prompt: event.target.value }, ["prompt"])}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit();
}
}}
placeholder={t("composer.mainPlaceholder")}
className={`nowheel h-36 w-full resize-none rounded-lg bg-transparent text-sm leading-6 outline-none placeholder:text-neutral-500 ${
promptReadOnly ? "text-neutral-300" : "text-neutral-100"
}`}
/>
{promptReadOnly && (
<div className="pb-2 text-[11px] text-cyan-200/80">
{t("composer.promptProvidedByUpstream")}
</div>
)}
</>
)}
</div>
{errorMessage && (
<div className="mx-4 mb-2 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 items-center gap-3 border-t border-neutral-700/70 px-4 py-3 text-sm text-neutral-300">
{isProcessNode ? (
<div className="flex min-w-0 items-center gap-2 text-neutral-400">
<span className={isEaseCurveNode ? "text-lime-300" : "text-pink-300"}></span>
<span className="truncate">
{isEaseCurveNode
? ((connectedInputs?.videos.length ?? 0) >= 1
? t("composer.easeCurveReady")
: t("composer.easeCurveNoVideo"))
: ((connectedInputs?.videos.length ?? 0) >= 2
? t("composer.videoStitchReady")
: t("composer.videoStitchNeedTwo"))}
</span>
</div>
) : (
<button
type="button"
onClick={() => canChooseModel && setModelDialogOpen(true)}
disabled={!canChooseModel}
aria-label={t("composer.selectModel")}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-neutral-100 transition-colors hover:bg-neutral-700/70 disabled:cursor-not-allowed disabled:text-neutral-500"
title={t("composer.selectModelTitle", { model: getComposerModelLabel(draft.selectedModel) })}
>
<span className="text-lg text-cyan-300"></span>
<span>{getComposerModelLabel(draft.selectedModel)}</span>
<span className="text-neutral-500"></span>
</button>
)}
{!isProcessNode && activeCapability === "image" && (
<>
<select
value={draft.aspectRatio}
onChange={(event) => markDraft({ aspectRatio: event.target.value as AspectRatio }, ["aspectRatio"])}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{ASPECT_RATIOS.map((ratio) => (
<option key={ratio} value={ratio} className="bg-neutral-900">
{ratio}
</option>
))}
</select>
<select
value={draft.resolution}
onChange={(event) => markDraft({ resolution: event.target.value as Resolution }, ["resolution"])}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{RESOLUTIONS.map((item) => (
<option key={item} value={item} className="bg-neutral-900">
{item}
</option>
))}
</select>
<select
aria-label={t("composer.imageCount")}
value={draft.imageCount}
onChange={(event) =>
markDraft({ imageCount: Number(event.target.value) as ImageGenerationCount }, ["imageCount"])
}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{IMAGE_COUNTS.map((count) => (
<option key={count} value={count} className="bg-neutral-900">
{t("composer.imageCountOption", { count })}
</option>
))}
</select>
</>
)}
{!isProcessNode && activeCapability === "video" && (
<>
<select
aria-label={t("node.duration")}
value={draft.durationSeconds}
onChange={(event) =>
updateVideoQuickDraft({ durationSeconds: normalizeVideoDurationSeconds(event.target.value) }, ["durationSeconds"])
}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{videoDurationOptions.map((seconds) => (
<option key={seconds} value={seconds} className="bg-neutral-900">
{t("node.seconds", { count: seconds })}
</option>
))}
</select>
<select
aria-label={t("node.resolution")}
value={draft.resolution}
onChange={(event) => updateVideoQuickDraft({ resolution: event.target.value }, ["resolution"])}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{VIDEO_RESOLUTION_OPTIONS.map((item) => (
<option key={item} value={item} className="bg-neutral-900">
{item}
</option>
))}
</select>
{videoSoundSupported && (
<button
type="button"
role="switch"
aria-checked={draft.soundEnabled ?? true}
onClick={() => updateVideoQuickDraft({ soundEnabled: !(draft.soundEnabled ?? true) }, ["soundEnabled"])}
className={`rounded-md px-1.5 py-1 transition-colors ${
(draft.soundEnabled ?? true)
? "bg-cyan-500/15 text-cyan-200"
: "text-neutral-500 hover:bg-neutral-700/60 hover:text-neutral-300"
}`}
>
{t("node.sound")}
</button>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 transition-colors hover:bg-white disabled:cursor-not-allowed disabled:bg-neutral-600 disabled:text-neutral-400"
aria-label={t("composer.generate")}
title={generateTitle}
>
{isRunning ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-500 border-t-transparent" />
) : (
<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>
</div>
</div>
</ViewportPortal>
)}
{modelDialogOpen && canChooseModel && (
<ModelSearchDialog
isOpen={modelDialogOpen}
onClose={() => setModelDialogOpen(false)}
initialProvider={draft.selectedModel.provider || "newapiwg"}
initialCapabilityFilter={context.mode === "empty-create" ? "all" : context.capability}
title={context.mode === "node-edit" ? modelDialogTitle : t("composer.chooseModelTitle")}
onModelSelected={handleModelSelected}
/>
)}
{previewReferenceImage && typeof document !== "undefined" &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={t("composer.referenceImage", { index: (referencePreviewIndex ?? 0) + 1 })}
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/90 p-6"
onClick={() => setReferencePreviewIndex(null)}
>
<div className="relative max-h-full max-w-full" onClick={(event) => event.stopPropagation()}>
<img
src={previewReferenceImage}
alt={t("composer.referenceImage", { index: (referencePreviewIndex ?? 0) + 1 })}
className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl shadow-black"
/>
<button
type="button"
onClick={() => setReferencePreviewIndex(null)}
aria-label={t("common.close")}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded bg-black/60 text-white transition-colors hover:bg-black/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>,
document.body
)}
</>
);
}