diff --git a/README.md b/README.md index 300efaa..a43f7fa 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ For information on how to run each project, see the README in each directory. | [Mastra agents](/mastra-agents) | Example of using [Mastra](https://github.com/mastra-ai/mastra) agents with Trigger.dev | | [OpenAI Agent SDK Guardrails examples](/openai-agent-sdk-guardrails-examples) | Examples of using the [OpenAI Agent SDK](https://openai.github.io/openai-agents-python/) with guardrails and Trigger.dev | | [OpenAI Agents SDK for Typescript with Trigger.dev playground](/openai-agents-sdk-with-trigger-playground) | A playground to test and play with AI agents built with the OpenAI Agents SDK for Typescript and Trigger.dev | +| [Product image generator](/product-image-generator) | AI-powered product image generator that transforms basic product photos into professional marketing shots using Replicate's image generation models (`google/nano-banana`) and Trigger.dev for task orchestration | | [Python doc to markdown converter](/python-doc-to-markdown-converter) | Convert documents to Markdown using [Trigger.dev](https://trigger.dev) and [MarkItDown](https://github.com/microsoft/markitdown) | | [Python Crawl4ai](/python-crawl4ai) | Headless web crawler using Trigger.dev with Python, [Crawl4AI](https://github.com/triggerdotdev/examples/tree/main/python-crawl4ai), and [Playwright](https://playwright.dev/) | | [Python image processing](/python-image-processing) | Python-based ([Pillow](https://pillow.readthedocs.io/en/stable/)) image processing tasks and uploading to S3-compatible storage | diff --git a/product-image-generator/.gitignore b/product-image-generator/.gitignore new file mode 100644 index 0000000..ac7bf9b --- /dev/null +++ b/product-image-generator/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.trigger \ No newline at end of file diff --git a/product-image-generator/README.md b/product-image-generator/README.md new file mode 100644 index 0000000..3f62b40 --- /dev/null +++ b/product-image-generator/README.md @@ -0,0 +1,102 @@ +# Product Image Generator using Trigger.dev and Replicate + +AI-powered product image generator that transforms basic product photos into professional marketing shots using Replicate's image generation models and Trigger.dev for task orchestration. + +## Tech stack + +- [**Next.js**](https://nextjs.org/) – frontend React framework +- [**Replicate**](https://replicate.com/docs) – AI image generation +- [**Trigger.dev**](https://trigger.dev/docs) – background task orchestration +- [**UploadThing**](https://uploadthing.com/) – file upload handling +- [**Cloudflare R2**](https://developers.cloudflare.com/r2/) – image storage + +## Setup & Running locally + +1. **Clone the repository** + + ```bash + git clone + cd product-image-generator + ``` + +2. **Install dependencies** + + ```bash + pnpm install + ``` + +3. **Copy environment variables and configure** + + ```bash + cp env.example .env + ``` + + Fill in the required variables: + + - `TRIGGER_SECRET_KEY` – Get from [Trigger.dev dashboard](https://cloud.trigger.dev/) + - `REPLICATE_API_TOKEN` – Get from [Replicate](https://replicate.com/account/api-tokens) + - `UPLOADTHING_TOKEN` – Get from [UploadThing](https://uploadthing.com/) + - `R2_ACCOUNT_ID`, `R2_BUCKET`, `R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_PUBLIC_URL` – Configure Cloudflare R2 storage + +4. **Add Trigger.dev project reference** + + Update `trigger.config.ts` with your project ref: + + ```typescript + project: "your_project_ref_here"; + ``` + +5. **Start development servers** + + ```bash + # Terminal 1: Start Next.js dev server + pnpm dev + + # Terminal 2: Start Trigger.dev CLI + npx trigger.dev@latest dev + ``` + +## How it works + +Trigger.dev orchestrates the image generation workflow through two main tasks: + +1. **`generateImages`** – Batch coordinator that triggers multiple individual image generations ([`app/trigger/generate-images.ts`](app/trigger/generate-images.ts)) +2. **`generateImage`** – Individual image processor that: + - Enhances prompts with style-specific instructions + - Calls Replicate's `google/nano-banana` model + - Creates waitpoint tokens for async webhook handling + - Waits for Replicate completion via webhook callbacks + - Uploads generated images to Cloudflare R2 + - Returns public URLs for display + +**Process flow:** + +1. User selects and uploads product image to the website +2. Image is uploaded to UploadThing cloud storage +3. UploadThing's `onUploadComplete` callback triggers batch generation for 3 preset styles +4. Each style runs as separate Trigger.dev task with waitpoints for Replicate webhooks +5. Frontend receives real-time progress updates via Trigger.dev React hooks +6. Generated images are stored in Cloudflare R2 and displayed with download options + +**Style presets:** + +- **Clean Product Shot** – Professional white background with studio lighting +- **Lifestyle Scene** – Person holding product with natural lighting +- **Hero Shot** – Elegant hands presenting product with dramatic lighting + +## Relevant code + +- **Image generation tasks** – Batch processing with waitpoints for Replicate webhook callbacks ([`app/trigger/generate-images.ts`](app/trigger/generate-images.ts)) +- **Upload handler** – UploadThing integration that triggers batch generation for 3 preset styles ([`app/api/uploadthing/core.ts`](app/api/uploadthing/core.ts)) +- **Real-time progress UI** – Live task updates using Trigger.dev React hooks ([`app/components/GeneratedCard.tsx`](app/components/GeneratedCard.tsx)) +- **Custom prompt interface** – User-defined style generation with custom prompts ([`app/components/CustomPromptCard.tsx`](app/components/CustomPromptCard.tsx)) +- **Main app component** – Layout and state management with professional style presets ([`app/ProductImageGenerator.tsx`](app/ProductImageGenerator.tsx)) +- **Trigger.dev configuration** – Project settings and task directories ([`trigger.config.ts`](trigger.config.ts)) + +## Learn more + +- [**Trigger.dev waitpoints**](https://trigger.dev/docs/wait-for-token) – pause tasks for async webhook callbacks +- [**Trigger.dev React hooks**](https://trigger.dev/docs/frontend/react-hooks) – real-time task updates and frontend integration +- [**Trigger.dev batch operations**](https://trigger.dev/docs/tasks/batch-trigger) – parallel task execution patterns +- [**Replicate API**](https://replicate.com/docs/get-started/nextjs) – AI model integration and webhook handling +- [**UploadThing**](https://docs.uploadthing.com/) – file upload handling and server callbacks diff --git a/product-image-generator/app/ProductImageGenerator.tsx b/product-image-generator/app/ProductImageGenerator.tsx new file mode 100644 index 0000000..773ddb9 --- /dev/null +++ b/product-image-generator/app/ProductImageGenerator.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Download, Home, Settings, User, WandSparklesIcon } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { GeneratedCard } from "./components/GeneratedCard"; +import { Button } from "./components/ui/button"; +import { UploadCard } from "./components/UploadCard"; +import CustomPromptCard from "./components/CustomPromptCard"; +import Link from "next/link"; + +const promptTitles = { + "isolated-table": "Clean Product Shot", + "lifestyle-scene": "Lifestyle Scene", + "hero-shot": "Hero Shot", +}; + +export function ProductImageGenerator() { + const searchParams = useSearchParams(); + const publicAccessToken = searchParams.get("publicAccessToken"); + const generateToken = searchParams.get("triggerToken"); + const fileUrl = searchParams.get("fileUrl"); + const runId = searchParams.get("runId"); + + return ( +
+
+
+
+
+
+ + + +

ImageFlow

+
+
+ + +
+
+
+ +
+
+
+
+

+ Product Image Generator +

+

+ Upload a product image and generate professional marketing shots + for your online store. +

+
+
+ +
+
+ +
+ + + + +
+ +
+ {Array.from({ length: 4 }).map((_, index) => { + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/product-image-generator/app/api/uploadthing/core.ts b/product-image-generator/app/api/uploadthing/core.ts new file mode 100644 index 0000000..593da49 --- /dev/null +++ b/product-image-generator/app/api/uploadthing/core.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "crypto"; +import { createUploadthing, type FileRouter } from "uploadthing/next"; +import { UploadThingError } from "uploadthing/server"; +import type { generateImages } from "@/trigger/generate-images"; +import { auth, tasks } from "@trigger.dev/sdk"; + +const f = createUploadthing(); + +const mockAuth = (req: Request) => ({ id: randomUUID() }); // Fake auth function + +// FileRouter for your app, can contain multiple FileRoutes +export const ourFileRouter = { + // Define as many FileRoutes as you like, each with a unique routeSlug + imageUploader: f({ image: { maxFileSize: "4MB" } }) + // Set permissions and file types for this FileRoute + .middleware(async ({ req }) => { + // This code runs on your server before upload + const user = await mockAuth(req); + + // If you throw, the user will not be able to upload + if (!user) throw new UploadThingError("Unauthorized"); + + // Whatever is returned here is accessible in onUploadComplete as `metadata` + return { userId: user.id }; + }) + .onUploadComplete(async ({ metadata, file }) => { + // This code RUNS ON YOUR SERVER after upload + + const { id, publicAccessToken } = await tasks.trigger< + typeof generateImages + >("generate-images", { + images: [ + { + id: "isolated-table", + baseImageUrl: file.ufsUrl, + promptStyle: "isolated-table", + }, + { + id: "lifestyle-scene", + baseImageUrl: file.ufsUrl, + promptStyle: "lifestyle-scene", + }, + { + id: "hero-shot", + baseImageUrl: file.ufsUrl, + promptStyle: "hero-shot", + }, + ], + }); + + const triggerToken = await auth.createTriggerPublicToken( + "generate-image", + { + expirationTime: "20m", + multipleUse: true, // not recommended without an expiration time + }, + ); + + // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback + return { + uploadedBy: metadata.userId, + publicAccessToken, + triggerToken, + runId: id, + fileId: file.key, + fileUrl: file.ufsUrl, + }; + }), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/product-image-generator/app/api/uploadthing/route.ts b/product-image-generator/app/api/uploadthing/route.ts new file mode 100644 index 0000000..f8f1912 --- /dev/null +++ b/product-image-generator/app/api/uploadthing/route.ts @@ -0,0 +1,6 @@ +import { createRouteHandler } from "uploadthing/next"; +import { ourFileRouter } from "./core"; + +export const { GET, POST } = createRouteHandler({ + router: ourFileRouter, +}); diff --git a/product-image-generator/app/components/CustomPromptCard.tsx b/product-image-generator/app/components/CustomPromptCard.tsx new file mode 100644 index 0000000..6c3a24d --- /dev/null +++ b/product-image-generator/app/components/CustomPromptCard.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useTaskTrigger } from "@trigger.dev/react-hooks"; +import { RefreshCw, Send } from "lucide-react"; +import { useState } from "react"; +import type { generateImage } from "../trigger/generate-images"; +import { GeneratedCard } from "./GeneratedCard"; +import { Button } from "./ui/button"; +import { Card } from "./ui/card"; + +interface CustomPromptCardProps { + id: string; + fileUrl?: string; + generateToken?: string; +} + +export default function CustomPromptCard({ + id, + fileUrl, + generateToken, +}: CustomPromptCardProps) { + // Subscribe to the run if we have a runId and token + const { submit, handle, error, isLoading } = useTaskTrigger< + typeof generateImage + >("generate-image", { + accessToken: generateToken, + enabled: !!generateToken, + }); + + const [customPrompt, setCustomPrompt] = useState(""); + + return handle ? ( + + ) : ( + +
+
+ {fileUrl ? ( +
{ + e.preventDefault(); + submit({ + id, + baseImageUrl: fileUrl ?? "", + promptStyle: "custom", + customPrompt, + }); + }} + className="space-y-4" + > +
+ +