# Project export: Supernova

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2025
- Tagline: End-to-end AI-generated ads – the world's influencers at your fingertips.
- Devpost: https://devpost.com/software/supernova-p1tum5
- GitHub: https://github.com/pr28416/ai-ugc
- Demo: https://supernova0.vercel.app/
- Video: https://www.youtube.com/embed/785iDkVIIe0?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner (TreeHacks Grand Prize | 2nd Place ($7.5k Cash))
- Team: 4 GitHub contributor(s) — Iñaki Arango (31 commits), Pranav Ramesh (24 commits), Nikita Mounier (14 commits), DineshTeja (14 commits)

## Devpost submission (written by the team)

### Inspiration

The creator economy is evolving at lightning speed. With platforms like TikTok and Instagram fueling the rise of short-form user-generated content (UGC), influencer marketing has become a powerful way for brands to connect with their audiences. Yet, despite breakthroughs in generative AI—like dynamic video creation and realistic talking avatars—few platforms (including notable startups like Suno) have truly tapped into these tools to automate UGC ads from start to finish in a truly methodical (and not AI-sloppy) manner. We saw an exciting opportunity to leverage generative workflows and advanced AI research to radically streamline influencer-driven content and make it more accessible as a channel. That vision led us to build Supernova: a fully end-to-end, AI-powered ad generator with true access to the world’s knowledge.

### What it does

Supernova takes a single product link as its input and handles everything else: Autonomous Market Research Agents: Our autonomous AI agents powered by Perplexity continuously traverse the web, assembling a dynamic, self-evolving market research schema that updates over time. These agents aggregate and synthesize real-time data streams—uncovering solved pain points, mapping target audience behaviors, analyzing sentiment from customer reviews, tracking competitor positioning, and extracting domain-specific insights. The result is a continuously enriched, multi-dimensional market profile that sharpens product positioning and informs every creative decision with precision. Autonomous Market Research Agents: Our autonomous AI agents powered by Perplexity continuously traverse the web, assembling a dynamic, self-evolving market research schema that updates over time. These agents aggregate and synthesize real-time data streams—uncovering solved pain points, mapping target audience behaviors, analyzing sentiment from customer reviews, tracking competitor positioning, and extracting domain-specific insights. The result is a continuously enriched, multi-dimensional market profile that sharpens product positioning and informs every creative decision with precision. Deep Profiling for Influencer Selection: We deploy AI agents (Audience, Controversy, and Orchestrator Agents) to regularly monitor major news feeds, perform nested Perplexity searches, web scraping, and deep dives across platforms like social media and FamousBirthdays.com to synthesize comprehensive psychographic profiles of influencers and top celebrities. Our agents analyze their content themes, audience demographics, engagement patterns, controversies, and core belief systems. We cross-reference these insights with the product’s market profile to ensure precise alignment between the influencer’s persona and the target audience’s values and interests. Deep Profiling for Influencer Selection: We deploy AI agents (Audience, Controversy, and Orchestrator Agents) to regularly monitor major news feeds, perform nested Perplexity searches, web scraping, and deep dives across platforms like social media and FamousBirthdays.com to synthesize comprehensive psychographic profiles of influencers and top celebrities. Our agents analyze their content themes, audience demographics, engagement patterns, controversies, and core belief systems. We cross-reference these insights with the product’s market profile to ensure precise alignment between the influencer’s persona and the target audience’s values and interests. Precise B-Roll Generation: We craft a precise 30-second script tailored to the influencer’s persona, then deploy workflows searching for reference images and contextual details to design B-roll prompts. Our system determines whether the product should be featured in each segment and assembles detailed, tightly guided prompts to drive highly specific, on-brand B-roll generation using Runway ML's Gen3 Alpha Turbo image-to-video model. Precise B-Roll Generation: We craft a precise 30-second script tailored to the influencer’s persona, then deploy workflows searching for reference images and contextual details to design B-roll prompts. Our system determines whether the product should be featured in each segment and assembles detailed, tightly guided prompts to drive highly specific, on-brand B-roll generation using Runway ML's Gen3 Alpha Turbo image-to-video model. Hyper-Realistic AI Avatar Creation: We create a HeyGen or SyncLabs avatar (only for celebrity avatars), steering its appearance to closely match the influencer’s likeness through guided visual prompts and reference modeling. This ensures consistency with the influencer’s style and persona. Hyper-Realistic AI Avatar Creation: We create a HeyGen or SyncLabs avatar (only for celebrity avatars), steering its appearance to closely match the influencer’s likeness through guided visual prompts and reference modeling. This ensures consistency with the influencer’s style and persona. AI-Enhanced Voice Selection and Matching: Then, we start a voice optimization pipeline that focuses on finding a voice with tonality and expression that is optimal for the target demographic. We surf the web for psychographic data on individuals in the target audience and cross-reference it with the product’s positioning to understand emotional triggers, communication styles, and preferred tonal qualities. Then, using Cartesia’s voice-to-voice API, our AI agents look through a set of indexed voice profiles and select the most suitable one, optimizing for vocal delivery, emotional expression, and speech patterns to maximize audience conversions. AI-Enhanced Voice Selection and Matching: Then, we start a voice optimization pipeline that focuses on finding a voice with tonality and expression that is optimal for the target demographic. We surf the web for psychographic data on individuals in the target audience and cross-reference it with the product’s positioning to understand emotional triggers, communication styles, and preferred tonal qualities. Then, using Cartesia’s voice-to-voice API, our AI agents look through a set of indexed voice profiles and select the most suitable one, optimizing for vocal delivery, emotional expression, and speech patterns to maximize audience conversions. Seamless Final Assembly: Lastly, we stitch everything together with FFmpeg and add stylized captions using ZapCap, delivering a ready-to-post short-form video ad. Seamless Final Assembly: Lastly, we stitch everything together with FFmpeg and add stylized captions using ZapCap, delivering a ready-to-post short-form video ad.

### How we built it

We built Supernova using a multi-agent orchestration framework guided by OpenAI’s agent-first principles, enabling asynchronous operations and tool-based model calls for efficient task execution. Each major workflow was designed with specialized tools, running concurrently to maximize speed and accuracy. For market research, we integrated Perplexity’s API and Firecrawl Extract pipelines with a custom search schema builder, enabling recursive queries and nested searches for audience trends, pain points, and competitor insights. Real-time data aggregation from Brave Search and eCommerce reviews feeds into a vectorized knowledge base, which is continuously updated during the monitoring process (as news updates occur) to power downstream workflows. During influencer profiling, we used OpenAI models with tool-calling capabilities to execute searches, scrape data from social platforms and FamousBirthdays with Firecrawl, and extract insights from articles and interviews. Psychographic profiles are generated by scoring influencers on audience fit, controversies, and value alignment using weighted embeddings. A Llama 3.2 11B via Groq vision model matches these profiles against static HeyGen avatars to produce the closest visual likeness or, for celebrities, directly using SyncLabs on real video clips of the personality. For B-roll generation, we prompt gpt-4o to generate structured shot lists based on the influencer profile and market insights. Simultaneously, a reference search pipeline collects contextual images to steer Runway ML’s Gen3Alpha Turbo image-to-video generation model in generating accurate scenes with and without product placements (depending on b-roll requirements). In the voice pipeline, we first expand psychographic audience insights using agents with web scraping and search tools. Using a voice embedding search over indexed profiles, we select a voice that best matches audience preferences and fine-tune it for tonal accuracy and emotional resonance, properly create the new voice clips with Cartesia’s voice-to-voice API, and then match the new voice clips to the corresponding A-roll and B-roll segments for timestamps using OpenAI Whisper. Finally, video assembly is fully automated using FFmpeg for clip merging and synchronization. A ZapCap API layer adds captions and carefully selected music generated from the script, ensuring style consistency and platform readiness. This orchestrated, tool-based architecture allows Supernova to generate high-impact, data-informed video ads with minimal human intervention and maximum creative precision.

### Challenges we ran into

Getting the HeyGen avatars to match both the influencer’s tone and visual style without infringing on personal likeness rights was tricky. Generating consistent product shots via Runway ML required careful prompts and image references—especially when the product had unique packaging or shapes. Balancing multiple AI calls (research, generation, search) and aligning them with a coherent UI pipeline was a real juggling act. Ensuring each step awaited the correct data and kicked off the next step seamlessly took considerable design effort.

### Accomplishments we're proud of

We managed to create a truly one-click solution—from product link to final video output—by orchestrating multiple cutting-edge generative technologies. Our approach to matching influencers’ content style with product demographics, then mapping them to avatar equivalents, is both innovative and legally sound. The combination of Runway ML B-roll, refined voice-to-voice audio, and precise scripting elevates the generated content far beyond typical AI video demos.

### What we learned

Coordinating specialized AI models for research, generation, and video production can unlock powerful end products. Our pivot to avatar-likeness matching reminded us to prioritize IP rights and influencers’ personal brand. Generating realistic imagery and scripts with minimal repetition or artifacting is heavily dependent on fine-tuned prompts. This was our first time using Codeium Windsurf! We really enjoyed using this AI code editor and found it very useful in iterating quickly.

### What's next

We plan to integrate more robust metrics (like engagement rates, niche expertise) and widen our influencer catalog for more precise matching. We aim to extend Supernova to automatically generate videos in multiple languages and adapt them for platforms beyond TikTok and Instagram. A next step is allowing brands to tweak scripts, b-roll prompts, or avatar attributes in real time, providing a more interactive creative experience. We’re exploring post-release analytics to measure ad performance, feedback loops for improvement, and deeper integration of generative AI in the overall marketing pipeline. We envision a future where marketing becomes seamless and effortless—yet every frame of an advertisement is purposeful, delivering meaningful value to sellers, creators, and viewers alike. We'd love for you to check out our gallery of public-facing ads that we generated at the link below: https://supernova0.vercel.app/gallery

## README (from the GitHub repository)

# Supernova

End-to-end AI-generated ads – the world's influencers at your fingertips.

*Iñaki Arango, Nikita Mounier, Pranav Ramesh, Dinesh Vasireddy*


## Detected evidence (automated analysis)

Indexed codebase: 121 recognized source files, 640 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (120 of 137)

```
.gitignore
.python-version
.vscode/extensions.json
.vscode/settings.json
app/[productUrl]/product/components/final-video-view/components/ad-details.tsx
app/[productUrl]/product/components/final-video-view/components/loading-screen.tsx
app/[productUrl]/product/components/final-video-view/index.tsx
app/[productUrl]/product/components/influencers-view/index.tsx
app/[productUrl]/product/components/influencers-view/mock-data.ts
app/[productUrl]/product/components/intermediate-video-view/components/intermediate-video-player.tsx
app/[productUrl]/product/components/intermediate-video-view/components/scene-list.tsx
app/[productUrl]/product/components/intermediate-video-view/index.tsx
app/[productUrl]/product/components/market-strategy-view/components/source-icon.tsx
app/[productUrl]/product/components/market-strategy-view/index.tsx
app/[productUrl]/product/components/product-view/components/product-gallery.tsx
app/[productUrl]/product/components/product-view/components/product-info.tsx
app/[productUrl]/product/components/product-view/index.tsx
app/[productUrl]/product/components/storyboard-view/components/ad-scene-list/components/ad-scene-card.tsx
app/[productUrl]/product/components/storyboard-view/components/ad-scene-list/components/add-scene-card.tsx
app/[productUrl]/product/components/storyboard-view/components/ad-scene-list/index.tsx
app/[productUrl]/product/components/storyboard-view/index.tsx
app/[productUrl]/product/components/storyboard-view/mock-data.ts
app/[productUrl]/product/contexts/modules/final-video.ts
app/[productUrl]/product/contexts/modules/influencer-research.ts
app/[productUrl]/product/contexts/modules/market-research.ts
app/[productUrl]/product/contexts/modules/product-info.ts
app/[productUrl]/product/contexts/modules/product-research.ts
app/[productUrl]/product/contexts/modules/storyboarding/index.ts
app/[productUrl]/product/contexts/modules/storyboarding/utils/prompt.ts
app/[productUrl]/product/contexts/product-context.tsx
app/[productUrl]/product/page.tsx
app/api/ad/generate/route.ts
app/api/broll-gen/route.ts
app/api/broll-gen/services.ts
app/api/cartesia/substitute-voice/route.ts
app/api/cartesia/substitute-voice/services.ts
app/api/gallery/[product_id]/route.ts
app/api/gallery/route.ts
app/api/heygen/create/route.ts
app/api/heygen/create/services.ts
app/api/heygen/get-video/route.ts
app/api/heygen/get-video/services.ts
app/api/join/route.ts
app/api/join/schemas.ts
app/api/join/services.ts
app/api/product-info/route.ts
app/api/product-info/schema.ts
app/api/research/influencers/avatar_descriptions.py
app/api/research/influencers/fetch_avatars.py
app/api/research/influencers/get_influencers.py
app/api/research/influencers/influencer_images.py
app/api/research/influencers/influencer_research.py
app/api/research/influencers/influencer_to_avatar.py
app/api/research/influencers/route.ts
app/api/research/product/prompts.ts
app/api/research/product/route.ts
app/api/research/product/services.ts
app/api/research/product/types.ts
app/api/script/generate/prompts.ts
app/api/script/generate/route.ts
app/api/script/generate/schemas.ts
app/api/script/generate/services.ts
app/api/slick/route.ts
app/api/slick/services.ts
app/api/zapcap/generate/route.ts
app/api/zapcap/generate/services.ts
app/components/background.tsx
app/components/header.tsx
app/components/product-form.tsx
app/components/video-player.tsx
app/gallery/[product_id]/[video_id]/page.tsx
app/gallery/[product_id]/page.tsx
app/gallery/page.tsx
app/globals.css
app/layout.tsx
app/page.tsx
app/providers.tsx
app/theme-provider.tsx
components.json
components/magicui/animated-grid-pattern.tsx
components/magicui/aurora-text.tsx
components/magicui/line-shadow-text.tsx
components/magicui/rainbow-button.tsx
components/nav-main.tsx
components/nav-projects.tsx
components/nav-user.tsx
components/ui/app-sidebar.tsx
components/ui/avatar.tsx
components/ui/badge.tsx
components/ui/breadcrumb.tsx
components/ui/button.tsx
components/ui/card.tsx
components/ui/collapsible.tsx
components/ui/description-list.tsx
components/ui/dialog.tsx
components/ui/dropdown-menu.tsx
components/ui/header.tsx
components/ui/input.tsx
components/ui/multi-step-loader.tsx
components/ui/placeholders-and-vanish-input.tsx
components/ui/progress.tsx
components/ui/scroll-area.tsx
components/ui/separator.tsx
components/ui/sheet.tsx
components/ui/sidebar.tsx
components/ui/skeleton.tsx
components/ui/slider.tsx
components/ui/stepper.tsx
components/ui/table.tsx
components/ui/tabs.tsx
components/ui/theme-toggle.tsx
components/ui/tooltip.tsx
eslint.config.mjs
hooks/use-mobile.tsx
lib/ai.ts
lib/utils.ts
main.py
next.config.ts
package.json
postcss.config.mjs
[17 more files omitted for size]
```

### Dependencies

- package.json: @cartesia/cartesia-js@^2.1.5, @eslint/eslintrc@^3, @mendable/firecrawl-js@^1.17.0, @radix-ui/react-avatar@^1.1.3, @radix-ui/react-collapsible@^1.1.3, @radix-ui/react-dialog@^1.1.6, @radix-ui/react-dropdown-menu@^2.1.6, @radix-ui/react-progress@^1.1.2, @radix-ui/react-scroll-area@^1.2.3, @radix-ui/react-separator@^1.1.2, @radix-ui/react-slider@^1.2.3, @radix-ui/react-slot@^1.1.2, @radix-ui/react-tabs@^1.1.3, @radix-ui/react-tooltip@^1.1.8, @runwayml/sdk@^1.4.4, @stepperize/react@^5.1.0, @supabase/supabase-js@^2.48.1, @tabler/icons-react@^3.30.0, @tanstack/react-query@^5.66.0, @types/lodash@^4.17.15, @types/mime-types@^2.1.4, @types/node@^20, @types/react@^19, @types/react-dom@^19, class-variance-authority@^0.7.1, clsx@^2.1.1, eslint@^9, eslint-config-next@15.1.7, framer-motion@^12.4.3, fuse.js@^7.1.0, geist@^1.3.1, groq-sdk@^0.15.0, lodash@^4.17.21, lucide-react@^0.475.0, mime-types@^2.1.35, motion@^12.4.3, next@15.1.7, next-themes@^0.4.4, openai@^4.85.1, postcss@^8, react@^19.0.0, react-collapsible@^2.10.0, react-dom@^19.0.0, react-icons@^5.4.0, tailwind-merge@^3.0.1, tailwindcss@^3.4.1, tailwindcss-animate@^1.0.7, typescript@^5, zod@^3.24.2
- pyproject.toml: groq@>=0.18.0, supabase@>=2.13.0

### Recent commits (newest first)

- cleanup + overhaul
- removed unused import
- Turned off build warnings
- Updated readme
- Fixed bad bug
- Turned off build messages
- tweaks
- Added state stuff
- Added vid gen caching
- Removed animation from landing page
- Fixed final video generation
- fixed
- updated avatar ref
- modified avatar stuff
- Add loading screen
- Fixed stepper implementation
- Added video details
- Fixed sizing of video loading screen
- Added loader for video generation
- Video creation flow up to last view

## Key source files (fetched from GitHub, selected and truncated for size)

### pyproject.toml

```
[project]
name = "ai-ugc"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "groq>=0.18.0",
    "supabase>=2.13.0",
]

```

### package.json

```
{
  "name": "ai-ugc",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@cartesia/cartesia-js": "^2.1.5",
    "@mendable/firecrawl-js": "^1.17.0",
    "@radix-ui/react-avatar": "^1.1.3",
    "@radix-ui/react-collapsible": "^1.1.3",
    "@radix-ui/react-dialog": "^1.1.6",
    "@radix-ui/react-dropdown-menu": "^2.1.6",
    "@radix-ui/react-progress": "^1.1.2",
    "@radix-ui/react-scroll-area": "^1.2.3",
    "@radix-ui/react-separator": "^1.1.2",
    "@radix-ui/react-slider": "^1.2.3",
    "@radix-ui/react-slot": "^1.1.2",
    "@radix-ui/react-tabs": "^1.1.3",
    "@radix-ui/react-tooltip": "^1.1.8",
    "@runwayml/sdk": "^1.4.4",
    "@stepperize/react": "^5.1.0",
    "@supabase/supabase-js": "^2.48.1",
    "@tabler/icons-react": "^3.30.0",
    "@tanstack/react-query": "^5.66.0",
    "@types/mime-types": "^2.1.4",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "framer-motion": "^12.4.3",
    "fuse.js": "^7.1.0",
    "geist": "^1.3.1",
    "groq-sdk": "^0.15.0",
    "lodash": "^4.17.21",
    "lucide-react": "^0.475.0",
    "mime-types": "^2.1.35",
    "motion": "^12.4.3",
    "next": "15.1.7",
    "next-themes": "^0.4.4",
    "openai": "^4.85.1",
    "react": "^19.0.0",
    "react-collapsible": "^2.10.0",
    "react-dom": "^19.0.0",
    "react-icons": "^5.4.0",
    "tailwind-merge": "^3.0.1",
    "tailwindcss-animate": "^1.0.7",
    "zod": "^3.24.2"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3",
    "@types/lodash": "^4.17.15",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "15.1.7",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  },
  "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}

```

### main.py

```python
def main():
    print("Hello from ai-ugc!")


if __name__ == "__main__":
    main()

```

### app/page.tsx

```typescript
// import { Background } from "./components/background";
import { Header } from "./components/header";
import { ProductForm } from "./components/product-form";

export default function Home() {
  return (
    <div className="w-full min-h-screen flex flex-col items-center px-6 py-16 md:p-20">
      {/* <Background /> */}
      <Header />
      <ProductForm />
    </div>
  );
}

```

### app/layout.tsx

```typescript
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/ui/app-sidebar";
import { Header } from "@/components/ui/header";
import { cookies } from "next/headers";
// import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { GlobalProviders } from "./providers";
// import { createServerClient } from '@supabase/ssr';

export const metadata: Metadata = {
  title: "Supernova",
  description: "High quality UGC for your brand",
};

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
};

const SIDEBAR_STATE_COOKIE = "sidebar:state";

// const queryClient = new QueryClient();

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Get the session server-side
  const cookieStore = await cookies();
  // const supabase = createServerClient(
  //   process.env.NEXT_PUBLIC_SUPABASE_URL!,
  //   process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  //   {
  //     cookies: {
  //       get(name: string) {
  //         return cookieStore.get(name)?.value;
  //       },
  //     },
  //   }
  // );

  // const { data: { session } } = await supabase.auth.getSession();

  const sidebarState = cookieStore.get(SIDEBAR_STATE_COOKIE)?.value === "true";

  return (
    <html lang="en">
      <body suppressHydrationWarning className="font-sans antialiased">
        <GlobalProviders>
          <SidebarProvider defaultOpen={sidebarState}>
            <div className="flex h-screen w-[100%]">
              <AppSidebar />
              <div className="flex flex-col flex-1 overflow-hidden w-[100%]">
                <div className="h-16 shrink-0">
                  <Header />
                </div>
                <main className="flex-1 overflow-auto w-[100%]">
                  {children}
                </main>
              </div>
            </div>
          </SidebarProvider>
        </GlobalProviders>
      </body>
    </html>
  );
}

```

### app/gallery/page.tsx

```typescript
"use client";

import { ExternalLink, Play, Clock, Share2, Download, FileVideo } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useEffect, useRef } from "react";

import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { Tables } from "@/supabase/types";
import { ConsolidatedResearch } from "@/app/api/research/product/types";
import { ProductInfo } from "@/app/api/product-info/schema";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { VideoPlayer } from "@/app/components/video-player";

type ProductGroupData = {
    scripts: Array<Tables<"scripts">>;
    research: (Tables<"research"> & {
        product_research: ConsolidatedResearch;
        product_info: ProductInfo;
    }) | undefined;
    bRoll: Array<Tables<"b_roll">>;
};

type GalleryData = {
    scripts: Array<Tables<"scripts">>;
    research: Array<Tables<"research">>;
    bRoll: Array<Tables<"b_roll">>;
};

type ScriptSegment = {
    content?: string;
    text?: string;
    roll_type: string;
    description?: string;
    start?: number;
    end?: number;
};

export default function GalleryPage() {
    const [data, setData] = useState<GalleryData | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        async function fetchGalleryData() {
            try {
                const response = await fetch("/api/gallery");
                const galleryData = await response.json();
                setData(galleryData);
            } catch (error) {
                console.error("Error fetching gallery data:", error);
            } finally {
                setLoading(false);
            }
        }

        fetchGalleryData();
    }, []);

    if (loading) {
        return <GalleryLoadingSkeleton />;
    }

    if (!data) {
        return <div>Error loading gallery data</div>;
    }

    // Group content by product_link
    const productGroups = data.scripts.reduce((groups, script) => {
        const group = groups[script.product_link] || {
            scripts: [],
            research: data.research.find(r => r.product_link === script.product_link),
            bRoll: data.bRoll.filter(b => b.product_link === script.product_link)
        };

        group.scripts.push(script);
        groups[script.product_link] = group;
        return groups;
    }, {} as Record<string, ProductGroupData>);

    return (
        <div className="w-full min-h-screen p-6 space-y-8">
            <header className="space-y-2">
                <h1 className="text-2xl font-medium tracking-tight">Content Gallery</h1>
                <p className="text-muted-foreground">
                    Browse all generated marketing content
                </p>
            </header>

            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
                {Object.entries(productGroups).map(([productLink, group]) => (
                    <ProductSection
                        key={productLink}
                        productLink={productLink}
                        group={group}
                    />
                ))}
            </div>
        </div>
    );
}

function ProductSection({ productLink, group }: { productLink: string; group: ProductGroupData }) {
    const router = useRouter();
    const research = group.research?.product_research;

    const handleProductClick = () => {
        localStorage.setItem(`gallery-${productLink}`, JSON.stringify(group));
        router.push(`/gallery/${encodeURIComponent(productLink)}`);
    };

    return (
        <div className="space-y-4">
            <div className="space-y-2">
                <div className="flex items-center justify-between">
                    <h2 className="text-lg font-medium truncate">
                        {group.research?.product_info?.name ?? "Untitled Product"}
                    </h2>
                    <Button
                        variant="outline"
                        size="sm"
                        onClick={handleProductClick}
                        className="shrink-0"
                    >
                        <ExternalLink className="w-4 h-4" />
                    </Button>
                </div>
                <p className="text-sm text-muted-foreground line-clamp-2">
                    {research?.summary?.productSummary?.overview}
                </p>
            </div>

            <div className="space-y-4">
                {group.scripts.map((script) => (
                    <VideoCard
                        key={script.id}
                        script={script}
                        research={group.research}
                    />
                ))}
            </div>
        </div>
    );
}

function VideoCard({
    script,
    research,
}: {
    script: Tables<"scripts">;
    research: (Tables<"research"> & {
        product_research: ConsolidatedResearch;
        product_info: ProductInfo;
    }) | undefined;
}) {
    const videoRef = useRef<HTMLVideoElement>(null);
    const [showDialog, setShowDialog] = useState(false);
    const formattedDate = new Date(script.created_at).toISOString().split('T')[0];

    const handleMouseEnter = () => {
        if (videoRef.current) {
            videoRef.current.play().catch(err => {
                console.log("Video play failed:", err);
            });
        }
    };

    const handleMouseLeave = () => {
        if (videoRef.current) {
            videoRef.current.pause();
            videoRef.current.currentTime = 0;
        }
    };

    useEffect(() => {
        if (script.processed_video_link) {
            console.log("Video URL:", script.processed_video_link);
            // Test if the URL is accessible
            fetch(script.processed_video_link)
                .then(response => 
[truncated — 14030 more characters]
```

### app/api/slick/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { addSubtitlesToVideo } from "./services";

type RequestBody = {
    videoId: string;
    theme: string;
};

export async function POST(request: NextRequest) {
    try {
        const { videoId, theme }: RequestBody = await request.json();

        if (!videoId || !theme) {
            return NextResponse.json(
                { error: "Video ID and theme are required" },
                { status: 400 },
            );
        }

        const { subtitled_video_link, error } = await addSubtitlesToVideo(
            videoId,
            theme,
        );

        if (error) {
            return NextResponse.json(
                { error: `Failed to add subtitles: ${error}` },
                { status: 500 },
            );
        }

        return NextResponse.json({ subtitled_video_link });
    } catch (error) {
        console.error("Error in Slick route:", error);
        return NextResponse.json(
            { error: "Failed to process request" },
            { status: 500 },
        );
    }
}

```

### app/api/gallery/route.ts

```typescript
import { NextResponse } from 'next/server'
import { getSupabase } from "@/supabase/utils";

export async function GET() {
  try {
    const supabase = getSupabase();

    const { data: scripts, error: scriptsError } = await supabase
      .from('scripts')
      .select('*')
      .not('processed_video_link', 'is', null)

    if (scriptsError) throw scriptsError

    const uniqueProductLinks = [...new Set(scripts.map(script => script.product_link))]

    const { data: research, error: researchError } = await supabase
      .from('research')
      .select('*')
      .in('product_link', uniqueProductLinks)

    if (researchError) throw researchError

    const { data: bRoll, error: bRollError } = await supabase
      .from('b_roll')
      .select('*')
      .in('product_link', uniqueProductLinks)

    if (bRollError) throw bRollError

    const response = {
      scripts,
      research,
      bRoll,
    }

    return NextResponse.json(response)
  } catch (error) {
    console.error('Error in gallery route:', error)
    return NextResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    )
  }
}

```

### app/api/join/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { joinVideos } from "./services";
import { ExtractStructuredScriptSchema } from "../script/generate/schemas";
import { getSupabase } from "@/supabase/utils";

type RequestBody = {
  scriptId: string;
  mainVideoUrl: string;
  script: ExtractStructuredScriptSchema;
  brollVideos: string[];
  dimensions?: { width: number; height: number };
};

export async function POST(request: NextRequest) {
  try {
    const {
      scriptId,
      mainVideoUrl,
      script,
      brollVideos,
      dimensions,
    }: RequestBody = await request.json();

    if (!scriptId || !mainVideoUrl || !script || !brollVideos) {
      return NextResponse.json(
        { error: "Missing required fields" },
        { status: 400 },
      );
    }

    const {
      url: processed_video_link,
      error,
      scriptSegmentsWithTimestamps,
    } = await joinVideos(mainVideoUrl, script, brollVideos, dimensions);

    if (error) {
      return NextResponse.json(
        { error: `Video joining failed: ${error}` },
        { status: 500 },
      );
    }
    const supabase = getSupabase();
    const { error: updateError } = await supabase
      .from("scripts")
      .update({
        processed_video_link,
        structured_script: scriptSegmentsWithTimestamps,
        b_roll_used: brollVideos,
      })
      .eq("id", scriptId);

    if (updateError) {
      throw new Error(`Failed to update script: ${updateError.message}`);
    }

    return NextResponse.json({
      processed_video_link,
      scriptSegmentsWithTimestamps,
    });
  } catch (error) {
    console.error("Error in join route:", error);
    return NextResponse.json(
      { error: "Failed to process request" },
      { status: 500 },
    );
  }
}

```

### app/api/broll-gen/route.ts

```typescript
import { NextResponse } from "next/server";
import RunwayML from "@runwayml/sdk";
import { generateBRollVideos } from "./services";
import { getSupabase } from "@/supabase/utils";
import { ExtractStructuredScriptSchema } from "../script/generate/schemas";

type RequestBody = {
  imageUrl: string;
  script: ExtractStructuredScriptSchema;
  product_link: string;
};

export async function POST(request: Request) {
  try {
    const { imageUrl, script, product_link }: RequestBody = await request
      .json();

    if (!imageUrl || !script || !product_link) {
      return NextResponse.json(
        { error: "Missing required fields" },
        { status: 400 },
      );
    }

    const videos = await generateBRollVideos(imageUrl, script);
    const supabase = getSupabase();

    console.log("Videos:", videos.length);

    // Upload videos and store metadata
    const uploadResults = await Promise.all(
      videos.map(async (video) => {
        // Upload to storage
        const { error: uploadError } = await supabase.storage
          .from("b-roll")
          .upload(video.filename, video.videoBuffer, {
            contentType: "video/mp4",
            upsert: true,
          });

        if (uploadError) {
          throw new Error(`Failed to upload video: ${uploadError.message}`);
        }

        // Get public URL
        const { data: urlData } = supabase.storage
          .from("b-roll")
          .getPublicUrl(video.filename);

        // Store in database
        const { error: dbError } = await supabase
          .from("b_roll")
          .insert({
            product_link,
            description: video.description,
            video_link: urlData.publicUrl,
          });

        if (dbError) {
          throw new Error(`Failed to store B-roll data: ${dbError.message}`);
        }

        console.log("Stored B-roll data:", {
          product_link,
          description: video.description,
          video_link: urlData.publicUrl,
          video,
        });

        return {
          ...video,
          videoBuffer: undefined,
          video_link: urlData.publicUrl,
        };
      }),
    );

    return NextResponse.json({
      success: true,
      videos: uploadResults,
    });
  } catch (error) {
    if (error instanceof RunwayML.APIError) {
      return NextResponse.json(
        { error: error.message },
        { status: error.status },
      );
    }

    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 },
    );
  }
}

```

[108 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]