feat: initial project scaffold

- Next.js 14 with App Router
- Prisma schema for Pipeline and Stage models
- Pipeline state machine (manager.ts)
- API routes: health, pipelines CRUD, start
- Landing page with pipeline visualization
- Dark theme with Tailwind CSS
- Environment setup with .env.example
This commit is contained in:
Chora
2026-02-01 20:07:05 -06:00
parent af22ad7271
commit ce684b70c0
18 changed files with 791 additions and 0 deletions

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Environment variables
# Copy to .env.local and fill in values
# Database (Vercel Postgres)
DATABASE_URL="postgresql://..."
# Vercel Blob Storage
BLOB_READ_WRITE_TOKEN="..."
# ElevenLabs (Voice synthesis)
ELEVENLABS_API_KEY="..."
ELEVENLABS_VOICE_ID="..."
# OpenAI (Script generation)
OPENAI_API_KEY="..."
# Pexels (Stock footage)
PEXELS_API_KEY="..."
# Base Chain (Token operations)
BASE_RPC_URL="https://mainnet.base.org"
WALLET_PRIVATE_KEY="..." # Only needed for token deployment
# Openwork (Hackathon API)
OPENWORK_API_KEY="..."

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Dependencies
node_modules/
.pnpm-store/
# Next.js
.next/
out/
# Prisma
prisma/migrations/
# Environment
.env
.env.local
.env*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build
*.tsbuildinfo
next-env.d.ts
# Vercel
.vercel
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

8
next.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ['@prisma/client'],
},
}
module.exports = nextConfig

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "cutroom",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^6.3.1",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next": "14.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"prisma": "^6.3.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

70
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,70 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Pipeline {
id String @id @default(cuid())
topic String
description String?
status PipelineStatus @default(DRAFT)
currentStage StageName @default(RESEARCH)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stages Stage[]
@@index([status])
@@index([createdAt])
}
model Stage {
id String @id @default(cuid())
pipelineId String
name StageName
status StageStatus @default(PENDING)
agentId String?
agentName String?
input Json?
output Json?
artifacts String[] @default([])
error String?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
@@unique([pipelineId, name])
@@index([status])
@@index([agentId])
}
enum PipelineStatus {
DRAFT
RUNNING
COMPLETE
FAILED
}
enum StageStatus {
PENDING
CLAIMED
RUNNING
COMPLETE
FAILED
SKIPPED
}
enum StageName {
RESEARCH
SCRIPT
VOICE
MUSIC
VISUAL
EDITOR
PUBLISH
}

View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({
status: 'ok',
service: 'cutroom',
timestamp: new Date().toISOString()
})
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPipeline, startPipeline } from '@/lib/pipeline/manager'
// GET /api/pipelines/[id] - Get pipeline detail
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const pipeline = await getPipeline(params.id)
if (!pipeline) {
return NextResponse.json(
{ error: 'Pipeline not found' },
{ status: 404 }
)
}
return NextResponse.json(pipeline)
} catch (error) {
console.error('Error getting pipeline:', error)
return NextResponse.json(
{ error: 'Failed to get pipeline' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { startPipeline, getPipeline } from '@/lib/pipeline/manager'
// POST /api/pipelines/[id]/start - Start pipeline execution
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const existing = await getPipeline(params.id)
if (!existing) {
return NextResponse.json(
{ error: 'Pipeline not found' },
{ status: 404 }
)
}
if (existing.status !== 'DRAFT') {
return NextResponse.json(
{ error: 'Pipeline already started' },
{ status: 400 }
)
}
const pipeline = await startPipeline(params.id)
return NextResponse.json({
message: 'Pipeline started',
pipeline
})
} catch (error) {
console.error('Error starting pipeline:', error)
return NextResponse.json(
{ error: 'Failed to start pipeline' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { createPipeline, listPipelines } from '@/lib/pipeline/manager'
// GET /api/pipelines - List all pipelines
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const status = searchParams.get('status') as any
const limit = parseInt(searchParams.get('limit') || '20')
const pipelines = await listPipelines(limit, status || undefined)
return NextResponse.json({
pipelines,
count: pipelines.length
})
} catch (error) {
console.error('Error listing pipelines:', error)
return NextResponse.json(
{ error: 'Failed to list pipelines' },
{ status: 500 }
)
}
}
// POST /api/pipelines - Create new pipeline
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { topic, description } = body
if (!topic) {
return NextResponse.json(
{ error: 'Topic is required' },
{ status: 400 }
)
}
const pipeline = await createPipeline(topic, description)
return NextResponse.json(pipeline, { status: 201 })
} catch (error) {
console.error('Error creating pipeline:', error)
return NextResponse.json(
{ error: 'Failed to create pipeline' },
{ status: 500 }
)
}
}

32
src/app/globals.css Normal file
View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground: 250 250 250;
--background: 9 9 11;
}
body {
color: rgb(var(--foreground));
background: rgb(var(--background));
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgb(39 39 42);
}
::-webkit-scrollbar-thumb {
background: rgb(63 63 70);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(82 82 91);
}

51
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Cutroom',
description: 'Collaborative AI video production pipeline',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-zinc-950 text-zinc-100 min-h-screen`}>
<div className="flex flex-col min-h-screen">
<header className="border-b border-zinc-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<a href="/" className="flex items-center gap-2">
<span className="text-2xl">🎬</span>
<span className="font-bold text-xl">Cutroom</span>
</a>
<nav className="flex items-center gap-6">
<a href="/pipelines" className="text-zinc-400 hover:text-zinc-100 transition-colors">
Pipelines
</a>
<a
href="https://github.com/openwork-hackathon/team-cutroom"
target="_blank"
className="text-zinc-400 hover:text-zinc-100 transition-colors"
>
GitHub
</a>
</nav>
</div>
</header>
<main className="flex-1">
{children}
</main>
<footer className="border-t border-zinc-800 px-6 py-4 text-center text-zinc-500 text-sm">
Built with 🦞 for the Openwork Clawathon
</footer>
</div>
</body>
</html>
)
}

101
src/app/page.tsx Normal file
View File

@@ -0,0 +1,101 @@
import Link from 'next/link'
const STAGES = [
{ name: 'Research', emoji: '🔍', desc: 'Find facts and sources' },
{ name: 'Script', emoji: '📝', desc: 'Write the video script' },
{ name: 'Voice', emoji: '🎙️', desc: 'Synthesize narration' },
{ name: 'Music', emoji: '🎵', desc: 'Add background track' },
{ name: 'Visual', emoji: '🎨', desc: 'Source b-roll and images' },
{ name: 'Editor', emoji: '🎬', desc: 'Assemble final video' },
{ name: 'Publish', emoji: '🚀', desc: 'Post to platforms' },
]
export default function Home() {
return (
<div className="max-w-4xl mx-auto px-6 py-12">
{/* Hero */}
<div className="text-center mb-16">
<h1 className="text-5xl font-bold mb-4">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-teal-400">
Cutroom
</span>
</h1>
<p className="text-xl text-zinc-400 mb-8">
Collaborative AI video production pipeline
</p>
<p className="text-zinc-500 max-w-2xl mx-auto mb-8">
Multiple specialized agents work together to create short-form video content.
Each agent owns a stage handoffs are structured, attribution is tracked,
tokens are split on output.
</p>
<Link
href="/pipelines"
className="inline-flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-6 py-3 rounded-lg font-medium transition-colors"
>
View Pipelines
</Link>
</div>
{/* Pipeline visualization */}
<div className="mb-16">
<h2 className="text-2xl font-bold mb-6 text-center">How It Works</h2>
<div className="flex flex-wrap justify-center gap-4">
{STAGES.map((stage, i) => (
<div key={stage.name} className="flex items-center gap-2">
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 w-32 text-center">
<div className="text-2xl mb-1">{stage.emoji}</div>
<div className="font-medium text-sm">{stage.name}</div>
<div className="text-xs text-zinc-500 mt-1">{stage.desc}</div>
</div>
{i < STAGES.length - 1 && (
<span className="text-zinc-600 text-xl"></span>
)}
</div>
))}
</div>
</div>
{/* Features */}
<div className="grid md:grid-cols-3 gap-6 mb-16">
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<div className="text-2xl mb-2">🤝</div>
<h3 className="font-bold mb-2">Multi-Agent Collaboration</h3>
<p className="text-zinc-500 text-sm">
Different agents specialize in different stages. Research, writing,
voice synthesis, editing each handled by the best agent for the job.
</p>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<div className="text-2xl mb-2">📊</div>
<h3 className="font-bold mb-2">Attribution Tracking</h3>
<p className="text-zinc-500 text-sm">
Every contribution is recorded. When videos generate value,
tokens are split based on who did what.
</p>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<div className="text-2xl mb-2"></div>
<h3 className="font-bold mb-2">Structured Handoffs</h3>
<p className="text-zinc-500 text-sm">
Each stage produces structured output for the next.
No ambiguity, no dropped context, no miscommunication.
</p>
</div>
</div>
{/* CTA */}
<div className="text-center">
<p className="text-zinc-500 mb-4">
Built for the Openwork Clawathon
</p>
<a
href="https://www.openwork.bot/hackathon"
target="_blank"
className="text-cyan-400 hover:text-cyan-300 underline"
>
Learn more about the hackathon
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
export default function PipelinesPage() {
return (
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Pipelines</h1>
<button className="bg-cyan-600 hover:bg-cyan-500 text-white px-4 py-2 rounded-lg font-medium transition-colors">
+ New Pipeline
</button>
</div>
{/* Empty state */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-12 text-center">
<div className="text-4xl mb-4">🎬</div>
<h2 className="text-xl font-bold mb-2">No pipelines yet</h2>
<p className="text-zinc-500 mb-6">
Create your first video production pipeline to get started.
</p>
<button className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 py-3 rounded-lg font-medium transition-colors">
Create Pipeline
</button>
</div>
{/* Pipeline list (placeholder) */}
{/*
<div className="space-y-4">
{pipelines.map(pipeline => (
<PipelineCard key={pipeline.id} pipeline={pipeline} />
))}
</div>
*/}
</div>
)
}

11
src/lib/db/client.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

213
src/lib/pipeline/manager.ts Normal file
View File

@@ -0,0 +1,213 @@
import prisma from '@/lib/db/client'
import { PipelineStatus, StageStatus, StageName, Pipeline, Stage } from '@prisma/client'
// Stage execution order
export const STAGE_ORDER: StageName[] = [
'RESEARCH',
'SCRIPT',
'VOICE',
'MUSIC',
'VISUAL',
'EDITOR',
'PUBLISH'
]
// Get next stage in order
export function getNextStageName(current: StageName): StageName | null {
const idx = STAGE_ORDER.indexOf(current)
if (idx === -1 || idx === STAGE_ORDER.length - 1) return null
return STAGE_ORDER[idx + 1]
}
// Create a new pipeline with all stages
export async function createPipeline(topic: string, description?: string): Promise<Pipeline & { stages: Stage[] }> {
return prisma.pipeline.create({
data: {
topic,
description,
status: 'DRAFT',
currentStage: 'RESEARCH',
stages: {
create: STAGE_ORDER.map(name => ({
name,
status: 'PENDING'
}))
}
},
include: { stages: true }
})
}
// Start pipeline execution
export async function startPipeline(pipelineId: string): Promise<Pipeline> {
return prisma.pipeline.update({
where: { id: pipelineId },
data: { status: 'RUNNING' }
})
}
// Claim a stage for an agent
export async function claimStage(
pipelineId: string,
stageName: StageName,
agentId: string,
agentName: string
): Promise<Stage> {
// Verify pipeline is running
const pipeline = await prisma.pipeline.findUnique({
where: { id: pipelineId }
})
if (!pipeline || pipeline.status !== 'RUNNING') {
throw new Error('Pipeline not in running state')
}
// Verify this is the current stage or previous is complete
const stage = await prisma.stage.findUnique({
where: { pipelineId_name: { pipelineId, name: stageName } }
})
if (!stage) {
throw new Error('Stage not found')
}
if (stage.status !== 'PENDING') {
throw new Error('Stage not available for claiming')
}
// Check if previous stage is complete (if not first)
const stageIdx = STAGE_ORDER.indexOf(stageName)
if (stageIdx > 0) {
const prevStageName = STAGE_ORDER[stageIdx - 1]
const prevStage = await prisma.stage.findUnique({
where: { pipelineId_name: { pipelineId, name: prevStageName } }
})
if (!prevStage || (prevStage.status !== 'COMPLETE' && prevStage.status !== 'SKIPPED')) {
throw new Error('Previous stage not complete')
}
}
return prisma.stage.update({
where: { id: stage.id },
data: {
status: 'CLAIMED',
agentId,
agentName,
startedAt: new Date()
}
})
}
// Mark stage as running
export async function startStage(stageId: string): Promise<Stage> {
return prisma.stage.update({
where: { id: stageId },
data: { status: 'RUNNING' }
})
}
// Complete a stage with output
export async function completeStage(
stageId: string,
output: unknown,
artifacts: string[] = []
): Promise<{ stage: Stage; pipeline: Pipeline }> {
const stage = await prisma.stage.update({
where: { id: stageId },
data: {
status: 'COMPLETE',
output: output as any,
artifacts,
completedAt: new Date()
},
include: { pipeline: true }
})
// Check if pipeline is complete
const nextStageName = getNextStageName(stage.name)
let pipeline: Pipeline
if (!nextStageName) {
// This was the last stage
pipeline = await prisma.pipeline.update({
where: { id: stage.pipelineId },
data: { status: 'COMPLETE' }
})
} else {
// Update current stage pointer
pipeline = await prisma.pipeline.update({
where: { id: stage.pipelineId },
data: { currentStage: nextStageName }
})
}
return { stage, pipeline }
}
// Fail a stage
export async function failStage(stageId: string, error: string): Promise<{ stage: Stage; pipeline: Pipeline }> {
const stage = await prisma.stage.update({
where: { id: stageId },
data: {
status: 'FAILED',
error,
completedAt: new Date()
}
})
const pipeline = await prisma.pipeline.update({
where: { id: stage.pipelineId },
data: { status: 'FAILED' }
})
return { stage, pipeline }
}
// Get pipeline with all stages
export async function getPipeline(pipelineId: string) {
return prisma.pipeline.findUnique({
where: { id: pipelineId },
include: {
stages: {
orderBy: { createdAt: 'asc' }
}
}
})
}
// List pipelines
export async function listPipelines(limit = 20, status?: PipelineStatus) {
return prisma.pipeline.findMany({
where: status ? { status } : undefined,
include: { stages: true },
orderBy: { createdAt: 'desc' },
take: limit
})
}
// Get available stages (for agents looking for work)
export async function getAvailableStages() {
// Find stages that are PENDING and whose previous stage is COMPLETE
const runningPipelines = await prisma.pipeline.findMany({
where: { status: 'RUNNING' },
include: { stages: { orderBy: { createdAt: 'asc' } } }
})
const available: (Stage & { pipeline: Pipeline })[] = []
for (const pipeline of runningPipelines) {
for (let i = 0; i < pipeline.stages.length; i++) {
const stage = pipeline.stages[i]
if (stage.status !== 'PENDING') continue
// Check if previous stage is complete (or this is first stage)
if (i === 0 || ['COMPLETE', 'SKIPPED'].includes(pipeline.stages[i - 1].status)) {
available.push({ ...stage, pipeline })
}
}
}
return available
}

18
tailwind.config.js Normal file
View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
background: 'rgb(9, 9, 11)',
foreground: 'rgb(250, 250, 250)',
},
},
},
plugins: [require('tailwindcss-animate')],
}

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}