diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c5546e --- /dev/null +++ b/AGENTS.md @@ -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 \ No newline at end of file diff --git a/app/api/studio/generate/image/route.ts b/app/api/studio/generate/image/route.ts new file mode 100644 index 0000000..a47e4df --- /dev/null +++ b/app/api/studio/generate/image/route.ts @@ -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 } + ); + } +} diff --git a/app/api/studio/generate/status/route.ts b/app/api/studio/generate/status/route.ts new file mode 100644 index 0000000..c4af848 --- /dev/null +++ b/app/api/studio/generate/status/route.ts @@ -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 }); + } +} diff --git a/app/api/studio/generate/video/route.ts b/app/api/studio/generate/video/route.ts new file mode 100644 index 0000000..ad38df5 --- /dev/null +++ b/app/api/studio/generate/video/route.ts @@ -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 } + ); + } +} diff --git a/app/studio/page.tsx b/app/studio/page.tsx new file mode 100644 index 0000000..a0934c5 --- /dev/null +++ b/app/studio/page.tsx @@ -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 ; +} diff --git a/app/studio/studio-page.tsx b/app/studio/studio-page.tsx new file mode 100644 index 0000000..d6dbf08 --- /dev/null +++ b/app/studio/studio-page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { StudioCanvas } from "@/components/studio/studio-canvas"; +import { StudioPromptBar } from "@/components/studio/studio-prompt-bar"; +import { useStudioGeneration } from "@/hooks/use-studio-generation"; +import { useAuthStore } from "@/store/useAuthStore"; + +export default function StudioPage() { + const { isAuthenticated, isLoading } = useAuthStore(); + const router = useRouter(); + const { generate, state } = useStudioGeneration(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push(`/sign-in?redirect=${encodeURIComponent("/studio")}`); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/components/conditional-layout.tsx b/components/conditional-layout.tsx index 39024d4..e93ed65 100644 --- a/components/conditional-layout.tsx +++ b/components/conditional-layout.tsx @@ -13,12 +13,13 @@ export function ConditionalLayout({ const isAuthPage = pathname?.startsWith("/auth") || pathname === "/sign-in" || pathname === "/sign-up"; const isExplorePage = pathname?.startsWith("/explore"); const isSharePage = pathname?.startsWith("/share"); + const isStudioPage = pathname?.startsWith("/studio"); return ( <> - {!isAuthPage && !isExplorePage && !isSharePage && } + {!isAuthPage && !isExplorePage && !isSharePage && !isStudioPage && } {children} - {!isAuthPage && !isSharePage &&