Skip to content
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# AGENTS.md

This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.

## Development Commands

- **Start development server**: `npm run dev` (opens at http://localhost:3000)
- **Build for production**: `npm run build`
- **Start production server**: `npm start`
- **Lint code**: `npm run lint`

## Project Architecture

This is a Next.js 15 application using the App Router architecture with TypeScript and Tailwind CSS.

### Key Structure
- `/app/` - Next.js App Router directory containing pages and layouts
- `/app/layout.tsx` - Root layout with Geist font configuration and global styles
- `/app/page.tsx` - Homepage component
- `/app/globals.css` - Global styles with Tailwind CSS and custom CSS variables

### Technology Stack
- **Framework**: Next.js 15.5.4 with App Router
- **Language**: TypeScript with strict mode enabled
- **Styling**: Tailwind CSS 4 with custom CSS variables for theming
- **UI Components**: shadcn/ui for all UI components
- **Fonts**: Inter font family (update from Geist)
- **Linting**: ESLint with Next.js configuration

### Development Guidelines
- **UI Components**: Always use shadcn/ui components for consistent design system
- **Font**: Use Inter font family instead of Geist for better readability
- **Code Style**: Keep code simple, clean, and well-organized
- **File Organization**: Create proper file and folder structure with clear naming conventions

### Styling System
- Uses Tailwind CSS with a custom theme configuration in `globals.css`
- CSS variables for background/foreground colors with automatic dark mode support
- Font variables should be configured for Inter font family

### Path Configuration
- `@/*` alias points to the root directory for imports
108 changes: 108 additions & 0 deletions app/api/studio/generate/image/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import {
FAL_STUDIO_ENDPOINTS,
createGenerationErrorResponse,
submitImageGeneration,
} from "@/lib/fal/studio";
import type {
ImageAspectRatio,
ImageGenerationSettings,
ImageResolution,
} from "@/types/studio";

const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = [
"auto",
"1:1",
"16:9",
"9:16",
"4:3",
"3:4",
];

const IMAGE_RESOLUTIONS: ImageResolution[] = ["1024", "2048", "4096"];

function parseImageSettings(
prompt: string,
aspectRatioValue: FormDataEntryValue | null,
countValue: FormDataEntryValue | null,
resolutionValue: FormDataEntryValue | null,
attachments: File[]
): ImageGenerationSettings {
const aspectRatio = IMAGE_ASPECT_RATIOS.includes(
aspectRatioValue as ImageAspectRatio
)
? (aspectRatioValue as ImageAspectRatio)
: "1:1";
const count = Math.min(
4,
Math.max(1, Number.parseInt(String(countValue ?? "1"), 10) || 1)
);
const resolution = IMAGE_RESOLUTIONS.includes(
resolutionValue as ImageResolution
)
? (resolutionValue as ImageResolution)
: "2048";

return {
aspectRatio,
model: "nano-banana-2",
count,
resolution,
isEdit: attachments.length > 0,
attachmentCount: attachments.length,
};
}

export async function POST(request: NextRequest) {
const formData = await request.formData();
const prompt = String(formData.get("prompt") ?? "").trim();
const attachments = formData
.getAll("attachments")
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
const settings = parseImageSettings(
prompt,
formData.get("aspectRatio"),
formData.get("count"),
formData.get("resolution"),
attachments
);
const providerModel = settings.isEdit
? FAL_STUDIO_ENDPOINTS.imageEdit
: FAL_STUDIO_ENDPOINTS.image;

if (!prompt) {
return NextResponse.json(
createGenerationErrorResponse(
"image",
prompt,
providerModel,
settings,
new Error("Prompt is required.")
),
{ status: 400 }
);
}

try {
const generation = await submitImageGeneration({
prompt,
aspectRatio: settings.aspectRatio,
count: settings.count,
resolution: settings.resolution,
attachments,
});

return NextResponse.json(generation.response);
} catch (error) {
return NextResponse.json(
createGenerationErrorResponse(
"image",
prompt,
providerModel,
settings,
error
),
{ status: 500 }
);
}
}
97 changes: 97 additions & 0 deletions app/api/studio/generate/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { getGenerationStatus } from "@/lib/fal/studio";
import type {
GenerationJobResponse,
ImageGenerationSettings,
StudioMode,
VideoGenerationSettings,
} from "@/types/studio";

function parseRequestSettings(
mode: StudioMode,
payload: string | null
): ImageGenerationSettings | VideoGenerationSettings {
if (!payload) {
throw new Error("Missing request settings.");
}

const parsed = JSON.parse(payload) as
| ImageGenerationSettings
| VideoGenerationSettings;

if (mode === "image" && parsed.model !== "nano-banana-2") {
throw new Error("Invalid image request settings.");
}

if (mode === "video" && parsed.model !== "kling-3") {
throw new Error("Invalid video request settings.");
}

return parsed;
}

export async function POST(request: NextRequest) {
const body = (await request.json()) as {
requestId?: string;
endpointId?: string;
type?: StudioMode;
prompt?: string;
requestSettings?: string;
};
const requestId = body.requestId ?? null;
const endpointId = body.endpointId ?? null;
const type = body.type ?? null;
const prompt = body.prompt ?? "";
const requestSettingsPayload = body.requestSettings ?? null;

if (!requestId || !endpointId || (type !== "image" && type !== "video")) {
return NextResponse.json(
{
error: "Missing required generation status parameters.",
},
{ status: 400 }
);
}

try {
const requestSettings = parseRequestSettings(type, requestSettingsPayload);
const response = await getGenerationStatus(
endpointId,
requestId,
prompt,
type,
requestSettings
);

return NextResponse.json(response);
} catch (error) {
const failure: GenerationJobResponse = {
jobId: requestId,
status: "failed",
type,
outputs: [],
prompt,
providerModel: endpointId,
requestSettings:
type === "image"
? {
aspectRatio: "1:1",
model: "nano-banana-2",
count: 1,
resolution: "2048",
isEdit: false,
attachmentCount: 0,
}
: {
aspectRatio: "16:9",
duration: 5,
model: "kling-3",
audio: true,
},
createdAt: new Date().toISOString(),
error: error instanceof Error ? error.message : "Failed to fetch status.",
};

return NextResponse.json(failure, { status: 500 });
}
}
114 changes: 114 additions & 0 deletions app/api/studio/generate/video/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import {
FAL_STUDIO_ENDPOINTS,
createGenerationErrorResponse,
submitVideoGeneration,
} from "@/lib/fal/studio";
import type {
VideoAspectRatio,
VideoGenerationSettings,
} from "@/types/studio";

const VIDEO_ASPECT_RATIOS: VideoAspectRatio[] = ["16:9", "9:16", "1:1"];
const VIDEO_DURATIONS = new Set([3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);

function parseVideoSettings(
prompt: string,
aspectRatioValue: FormDataEntryValue | null,
durationValue: FormDataEntryValue | null,
audioValue: FormDataEntryValue | null,
firstFrame: File | null,
lastFrame: File | null
): VideoGenerationSettings {
const aspectRatio = VIDEO_ASPECT_RATIOS.includes(
aspectRatioValue as VideoAspectRatio
)
? (aspectRatioValue as VideoAspectRatio)
: "16:9";
const durationCandidate =
Number.parseInt(String(durationValue ?? "5"), 10) || 5;
const duration = VIDEO_DURATIONS.has(durationCandidate) ? durationCandidate : 5;
const audio = String(audioValue ?? "true") === "true";

return {
aspectRatio,
duration,
model: "kling-3",
audio,
firstFrame: firstFrame?.name,
lastFrame: lastFrame?.name ?? undefined,
};
}

export async function POST(request: NextRequest) {
const formData = await request.formData();
const prompt = String(formData.get("prompt") ?? "").trim();
const firstFrameEntry = formData.get("firstFrame");
const lastFrameEntry = formData.get("lastFrame");
const firstFrame =
firstFrameEntry instanceof File && firstFrameEntry.size > 0
? firstFrameEntry
: null;
const lastFrame =
lastFrameEntry instanceof File && lastFrameEntry.size > 0
? lastFrameEntry
: null;
const settings = parseVideoSettings(
prompt,
formData.get("aspectRatio"),
formData.get("duration"),
formData.get("audio"),
firstFrame,
lastFrame
);

if (!prompt) {
return NextResponse.json(
createGenerationErrorResponse(
"video",
prompt,
FAL_STUDIO_ENDPOINTS.video,
settings,
new Error("Prompt is required.")
),
{ status: 400 }
);
}

if (!firstFrame) {
return NextResponse.json(
createGenerationErrorResponse(
"video",
prompt,
FAL_STUDIO_ENDPOINTS.video,
settings,
new Error("A first frame is required for Kling 3 image-to-video.")
),
{ status: 400 }
);
}

try {
const generation = await submitVideoGeneration({
prompt,
aspectRatio: settings.aspectRatio,
duration: settings.duration,
audio: settings.audio,
firstFrame,
lastFrame,
});

return NextResponse.json(generation.response);
} catch (error) {
return NextResponse.json(
createGenerationErrorResponse(
"video",
prompt,
FAL_STUDIO_ENDPOINTS.video,
settings,
error
),
{ status: 500 }
);
}
}
11 changes: 11 additions & 0 deletions app/studio/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import StudioPage from "./studio-page";

export const metadata: Metadata = {
title: "Studio | Invook",
description: "Create AI-generated images and videos with Invook Studio",
};

export default function Studio() {
return <StudioPage />;
}
Loading