Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 119 additions & 115 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16",
"baseline-browser-mapping": "^2.10.0",
"jsdom": "^27.4.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.0.4",
Expand Down
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Modified with the help of Antigravity - Charlie
// Custom Next.js server with extended timeout for video generation
// Node.js default server.requestTimeout is 5 minutes (300,000ms)
// We extend it to 10 minutes for long-running fal.ai video generation
Expand Down
1 change: 1 addition & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Modified with the help of Antigravity - Charlie
"use client";

import { useEffect } from "react";
Expand Down
10 changes: 10 additions & 0 deletions src/components/nodes/BaseNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface BaseNodeProps {
headerButtons?: ReactNode;
titlePrefix?: ReactNode;
commentNavigation?: CommentNavigationProps;
handles?: ReactNode;
}

export function BaseNode({
Expand All @@ -56,6 +57,7 @@ export function BaseNode({
headerButtons,
titlePrefix,
commentNavigation,
handles,
}: BaseNodeProps) {
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds);
const groups = useWorkflowStore((state) => state.groups);
Expand Down Expand Up @@ -260,6 +262,14 @@ export function BaseNode({
${className}
`}
>
{/* Handles Overlay - Absolute sibling to everything else for perfect positioning */}
{handles && (
<div className="absolute inset-0 pointer-events-none z-10">
<div className="relative w-full h-full">
{handles}
</div>
</div>
)}
<div className="px-3 pt-2 pb-1 flex items-center justify-between shrink-0">
{/* Title Section */}
<div className="flex-1 min-w-0 flex items-center gap-1.5">
Expand Down
19 changes: 17 additions & 2 deletions src/components/nodes/GenerateAudioNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,30 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
const dynamicHandles = useMemo(() => {
if (!nodeData.inputSchema || nodeData.inputSchema.length === 0) return null;

let textCount = 0;
let imageCount = 0;

return nodeData.inputSchema.map((input, index) => {
const handleType = input.type === "image" ? "image" : "text";
const isImage = input.type === "image";
const handleType = isImage ? "image" : "text";

let handleId = "";
if (isImage) {
handleId = imageCount === 0 ? "image" : `image-${imageCount}`;
imageCount++;
} else {
handleId = textCount === 0 ? "text" : `text-${textCount}`;
textCount++;
}

return (
<Handle
key={input.name}
type="target"
position={Position.Left}
id={input.name}
id={handleId}
data-handletype={handleType}
data-schema-name={input.name}
style={{
background: handleType === "image" ? "rgb(34, 197, 94)" : "rgb(251, 191, 36)",
top: `${50 + (index - nodeData.inputSchema!.length / 2 + 0.5) * 20}px`,
Expand Down
104 changes: 45 additions & 59 deletions src/components/nodes/GenerateImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ const IMAGE_CAPABILITIES: ModelCapability[] = ["text-to-image", "image-to-image"

type NanoBananaNodeType = Node<NanoBananaNodeData, "nanoBanana">;

/**
* GenerateImageNode component for AI image generation.
* Handles model selection, parameter configuration, and image preview/history.
* @param props - Node properties from React Flow.
* @returns React component for the image generation node.
*/
export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNodeType>) {
const nodeData = data;
const commentNavigation = useCommentNavigation(id);
Expand Down Expand Up @@ -94,6 +100,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
}, [id, nodeData.model, nodeData.selectedModel, updateNodeData]);

// Fetch models from external providers when provider changes
/**
* Fetches available models from the currently selected provider.
* @returns A promise that resolves when models are loaded.
*/
const fetchModels = useCallback(async () => {
if (currentProvider === "gemini") {
setExternalModels([]);
Expand Down Expand Up @@ -144,6 +154,11 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
}, [fetchModels]);

// Handle provider change
/**
* Handles changes to the model provider.
* Resets model selection and parameters when provider changes.
* @param e - the change event from the select element.
*/
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const provider = e.target.value as ProviderType;
Expand Down Expand Up @@ -172,6 +187,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
);

// Handle model change for external providers
/**
* Handles changes to external models (Replicate, Fal, etc.).
* @param e - the change event from the select element.
*/
const handleExternalModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const modelId = e.target.value;
Expand Down Expand Up @@ -208,6 +227,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
[id, updateNodeData]
);

/**
* Handles model selection for Gemini-native models.
* @param e - Change event from selection.
*/
const handleModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const model = e.target.value as ModelType;
Expand Down Expand Up @@ -285,10 +308,18 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);

/**
* Triggers the regeneration of the current node's image.
*/
const handleRegenerate = useCallback(() => {
regenerateNode(id);
}, [id, regenerateNode]);

/**
* Loads an image by its unique ID from the local generations path.
* @param imageId - The ID of the image to load.
* @returns A promise resolving to the base64 image data or null if not found.
*/
const loadImageById = useCallback(async (imageId: string) => {
if (!generationsPath) {
console.error("Generations path not configured");
Expand Down Expand Up @@ -359,6 +390,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
}, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, loadImageById, updateNodeData]);

// Handle model selection from browse dialog
/**
* Handles model selection from the external browse dialog.
* @param model - The chosen provider model.
*/
const handleBrowseModelSelect = useCallback((model: ProviderModel) => {
const newSelectedModel: SelectedModel = {
provider: model.provider,
Expand Down Expand Up @@ -473,66 +508,17 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
headerAction={headerAction}
titlePrefix={titlePrefix}
commentNavigation={commentNavigation ?? undefined}
handles={
<>
<Handle type="target" position={Position.Left} id="image" style={{ top: "35%", pointerEvents: "auto" }} data-handletype="image" isConnectable={true} />
<div className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right" style={{ right: `calc(100% + 8px)`, top: "calc(35% - 18px)", color: "var(--handle-color-image)" }}>Image</div>
<Handle type="target" position={Position.Left} id="text" style={{ top: "65%", pointerEvents: "auto" }} data-handletype="text" isConnectable={true} />
<div className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right" style={{ right: `calc(100% + 8px)`, top: "calc(65% - 18px)", color: "var(--handle-color-text)" }}>Prompt</div>
<Handle type="source" position={Position.Right} id="image" style={{ top: "50%", pointerEvents: "auto" }} data-handletype="image" />
<div className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none" style={{ left: `calc(100% + 8px)`, top: "calc(50% - 18px)", color: "var(--handle-color-image)" }}>Image</div>
</>
}
>
{/* Input handles - ALWAYS use same IDs and positions for connection stability */}
{/* Image input at 35%, Text input at 65% - never changes regardless of model */}
<Handle
type="target"
position={Position.Left}
id="image"
style={{ top: "35%" }}
data-handletype="image"
isConnectable={true}
/>
{/* Image label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(35% - 18px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
<Handle
type="target"
position={Position.Left}
id="text"
style={{ top: "65%" }}
data-handletype="text"
isConnectable={true}
/>
{/* Prompt label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(65% - 18px)",
color: "var(--handle-color-text)",
}}
>
Prompt
</div>
{/* Output handle */}
<Handle
type="source"
position={Position.Right}
id="image"
style={{ top: "50%" }}
data-handletype="image"
/>
{/* Output label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style={{
left: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>

<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Preview area */}
Expand Down
Loading