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
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
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|