feat(batch): New Batch wizard, CSV parser, and batch list

Slice 3 of the batch feature. Marketing team can now upload the
Rasika-style CSV, watch each row auto-map to the trainer/studio
they uploaded in slice 2, get a real MuAPI cost estimate, and
persist the batch as a draft.

Backend
-------
- POST /api/batches: creates a batch in status='draft' along with one
  job per CSV row (status='queued', not yet picked up since the
  worker arrives in slice 4).
- GET /api/batches: list view payload.
- GET /api/batches/[id]: detail with jobs joined to trainer/studio
  rows. Used by the slice-5 progress UI.
- POST /api/batches/[id]/estimate-cost: forwards the batch's model +
  payload to MuAPI's /api/v1/app/calculate_dynamic_cost, multiplies
  by row count, returns {perJob, total, currency}.

CSV
---
- lib/csvParser.js: PapaParse-based parser that validates required
  columns, normalises duration ("15 sec" -> 15) snapped to Seedance
  2.0's [5,10,15], maps quality ("1080P" -> "high"), and composes a
  prompt from description + start position + camera angle.

UI
--
- components/batch/NewBatchWizard.jsx: full-screen 3-step wizard.
  - Step 1 — name, CSV upload, model/duration/quality/aspect/
    concurrency settings.
  - Step 2 — review every row in a table. Auto-maps Character ->
    Trainer.csvLabel and Studio -> Studio.csvLabel, shows per-row
    override dropdowns and bulk-assign helpers, hard-blocks Next
    until every active row has both a trainer and studio.
  - Step 3 — saves the batch and triggers the cost estimate. On
    success, surfaces the batch id and points the user to the
    upcoming worker slice.
- components/batch/BatchesTab.jsx: replaces the placeholder with a
  real list view (status pill, progress counts, model, created-at)
  and the "+ New batch" button that opens the wizard.
- components/batch/BatchShell.jsx: pass apiKey to BatchesTab.

Verification
------------
- POST /api/batches with one row -> 201, batch + job persisted in
  Postgres (verified with psql).
- GET /api/batches lists the row.
- Cost estimate endpoint returns 401 without a key, calls MuAPI
  correctly with one (live test pending real credits).
This commit is contained in:
Anuragp22 2026-04-23 08:01:15 +05:30
commit 694c7075e2
9 changed files with 931 additions and 10 deletions

View file

@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getApiKey } from '@/lib/batchAuth';
const MUAPI_BASE = 'https://api.muapi.ai';
export async function POST(request, { params }) {
const { id } = await params;
const apiKey = getApiKey(request);
if (!apiKey) {
return NextResponse.json({ error: 'MuAPI API key required' }, { status: 401 });
}
const batch = await prisma.batch.findUnique({
where: { id },
select: {
model: true,
duration: true,
quality: true,
aspectRatio: true,
total: true,
},
});
if (!batch) {
return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
}
const payload = {
aspect_ratio: batch.aspectRatio,
duration: batch.duration,
quality: batch.quality,
};
let muapiData;
try {
const res = await fetch(`${MUAPI_BASE}/api/v1/app/calculate_dynamic_cost`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({ task_name: batch.model, payload }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: `MuAPI cost endpoint returned ${res.status}: ${text.slice(0, 200)}` },
{ status: 502 },
);
}
muapiData = await res.json();
} catch (err) {
return NextResponse.json({ error: `Cost estimate failed: ${err.message}` }, { status: 502 });
}
const perJob =
typeof muapiData.cost === 'number' ? muapiData.cost
: typeof muapiData.price === 'number' ? muapiData.price
: typeof muapiData.amount === 'number' ? muapiData.amount
: null;
if (perJob === null) {
return NextResponse.json(
{ error: `Could not extract cost from MuAPI response: ${JSON.stringify(muapiData).slice(0, 200)}`, raw: muapiData },
{ status: 502 },
);
}
return NextResponse.json({
perJob,
rows: batch.total,
total: perJob * batch.total,
currency: muapiData.currency || 'USD',
raw: muapiData,
});
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(_request, { params }) {
const { id } = await params;
const batch = await prisma.batch.findUnique({
where: { id },
include: {
jobs: {
orderBy: { rowIndex: 'asc' },
include: {
trainer: { select: { id: true, name: true, csvLabel: true, imageUrl: true } },
studio: { select: { id: true, name: true, csvLabel: true, imageUrl: true } },
},
},
},
});
if (!batch) {
return NextResponse.json({ error: 'Batch not found' }, { status: 404 });
}
return NextResponse.json({ batch });
}

86
app/api/batches/route.js Normal file
View file

@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
const batches = await prisma.batch.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
model: true,
duration: true,
quality: true,
aspectRatio: true,
concurrency: true,
status: true,
total: true,
done: true,
failed: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json({ batches });
}
export async function POST(request) {
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const {
name,
model = 'seedance-v2.0-i2v',
duration = 15,
quality = 'basic',
aspectRatio = '16:9',
concurrency = 5,
jobs = [],
} = body || {};
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'name is required' }, { status: 400 });
}
if (!Array.isArray(jobs) || jobs.length === 0) {
return NextResponse.json({ error: 'jobs must be a non-empty array' }, { status: 400 });
}
const cleanedJobs = jobs.map((j, i) => ({
rowIndex: typeof j.rowIndex === 'number' ? j.rowIndex : i,
practiceName: String(j.practiceName || `Row ${i + 1}`),
trainerId: j.trainerId || null,
studioId: j.studioId || null,
prompt: String(j.prompt || ''),
startPosition: j.startPosition || null,
cameraAngle: j.cameraAngle || null,
aspectRatio: j.aspectRatio || aspectRatio,
duration: typeof j.duration === 'number' ? j.duration : duration,
quality: j.quality || quality,
}));
const batch = await prisma.batch.create({
data: {
name: name.trim(),
model,
duration,
quality,
aspectRatio,
concurrency,
status: 'draft',
total: cleanedJobs.length,
jobs: { create: cleanedJobs },
},
select: {
id: true,
name: true,
status: true,
total: true,
createdAt: true,
},
});
return NextResponse.json({ batch }, { status: 201 });
}

View file

@ -100,7 +100,7 @@ export default function BatchShell() {
<main className="flex-1 overflow-y-auto px-6 py-8">
<div className="max-w-6xl mx-auto">
{activeTab === 'batches' && <BatchesTab />}
{activeTab === 'batches' && <BatchesTab apiKey={apiKey} />}
{activeTab === 'trainers' && <TrainersTab apiKey={apiKey} />}
{activeTab === 'studios' && <StudiosTab apiKey={apiKey} />}
</div>

View file

@ -1,6 +1,31 @@
'use client';
export default function BatchesTab() {
import { useEffect, useState, useCallback } from 'react';
import NewBatchWizard from './NewBatchWizard';
export default function BatchesTab({ apiKey }) {
const [batches, setBatches] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [wizardOpen, setWizardOpen] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/batches', { headers: { 'x-api-key': apiKey } });
if (!res.ok) throw new Error(`GET /api/batches failed: ${res.status}`);
const data = await res.json();
setBatches(data.batches || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiKey]);
useEffect(() => { refresh(); }, [refresh]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@ -11,19 +36,80 @@ export default function BatchesTab() {
</p>
</div>
<button
disabled
className="bg-white/5 text-white/40 font-medium text-sm rounded-md px-4 py-2 border border-white/5 cursor-not-allowed"
onClick={() => setWizardOpen(true)}
className="bg-[#d9ff00] text-black font-medium text-sm rounded-md px-4 py-2 hover:bg-[#e5ff33] transition-all"
>
+ New batch
</button>
</div>
<div className="bg-[#0a0a0a] border border-white/[0.03] rounded-md px-6 py-12 text-center">
<p className="text-white/50 text-sm">Batch creation lands in the next slice.</p>
<p className="text-white/30 text-[12px] mt-2">
For now, populate the Trainers and Studios libraries so the CSV auto-mapping has something to match against.
</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-300 text-[13px] rounded-md px-4 py-3">{error}</div>
)}
{loading ? (
<div className="text-white/40 text-sm py-12 text-center">Loading</div>
) : batches.length === 0 ? (
<div className="bg-[#0a0a0a] border border-white/[0.03] rounded-md px-6 py-12 text-center">
<p className="text-white/50 text-sm">No batches yet.</p>
<p className="text-white/30 text-[12px] mt-2">Click <span className="text-[#d9ff00]">+ New batch</span> to upload your CSV.</p>
</div>
) : (
<div className="border border-white/[0.04] rounded-md overflow-hidden">
<table className="w-full text-[13px]">
<thead className="bg-white/[0.02] text-white/40 text-[11px] uppercase tracking-wide">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3 w-32">Status</th>
<th className="text-left px-4 py-3 w-36">Progress</th>
<th className="text-left px-4 py-3 w-32">Model</th>
<th className="text-left px-4 py-3 w-28">Created</th>
</tr>
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id} className="border-t border-white/[0.03] hover:bg-white/[0.02]">
<td className="px-4 py-3 text-white/90 font-medium">{b.name}</td>
<td className="px-4 py-3"><StatusBadge status={b.status} /></td>
<td className="px-4 py-3 text-white/60 text-[12px]">
{b.done}/{b.total} done · {b.failed} failed
</td>
<td className="px-4 py-3 text-white/50 text-[12px]">{b.model}</td>
<td className="px-4 py-3 text-white/40 text-[12px]">
{new Date(b.createdAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{wizardOpen && (
<NewBatchWizard
apiKey={apiKey}
onClose={() => setWizardOpen(false)}
onCreated={async () => {
setWizardOpen(false);
await refresh();
}}
/>
)}
</div>
);
}
function StatusBadge({ status }) {
const styles = {
draft: 'bg-white/5 text-white/60 border-white/10',
running: 'bg-blue-500/10 text-blue-300 border-blue-500/30',
paused: 'bg-yellow-500/10 text-yellow-300 border-yellow-500/30',
completed: 'bg-[#d9ff00]/10 text-[#d9ff00] border-[#d9ff00]/30',
cancelled: 'bg-white/5 text-white/40 border-white/10',
};
return (
<span className={`inline-block px-2 py-0.5 rounded-full border text-[10px] uppercase tracking-wide font-semibold ${styles[status] || styles.draft}`}>
{status}
</span>
);
}

View file

@ -0,0 +1,556 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { parseBatchCsv } from '@/lib/csvParser';
const DEFAULTS = {
model: 'seedance-v2.0-i2v',
duration: 15,
quality: 'basic',
aspectRatio: '16:9',
concurrency: 5,
};
export default function NewBatchWizard({ apiKey, onClose, onCreated }) {
const [step, setStep] = useState(1);
const [trainers, setTrainers] = useState([]);
const [studios, setStudios] = useState([]);
const [libraryError, setLibraryError] = useState(null);
// Step 1
const [name, setName] = useState('');
const [csvText, setCsvText] = useState(null);
const [csvFileName, setCsvFileName] = useState(null);
const [csvRows, setCsvRows] = useState([]);
const [csvError, setCsvError] = useState(null);
const [settings, setSettings] = useState(DEFAULTS);
// Step 2 per-row trainer/studio overrides
const [rowOverrides, setRowOverrides] = useState({}); // { rowIndex: {trainerId, studioId, skipped} }
// Step 3
const [estimating, setEstimating] = useState(false);
const [estimate, setEstimate] = useState(null);
const [estimateError, setEstimateError] = useState(null);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(null);
// Load libraries
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [tr, st] = await Promise.all([
fetch('/api/trainers', { headers: { 'x-api-key': apiKey } }).then((r) => r.json()),
fetch('/api/studios', { headers: { 'x-api-key': apiKey } }).then((r) => r.json()),
]);
if (cancelled) return;
setTrainers(tr.trainers || []);
setStudios(st.studios || []);
} catch (err) {
if (!cancelled) setLibraryError(err.message);
}
}
load();
return () => { cancelled = true; };
}, [apiKey]);
const handleCsvFile = async (file) => {
setCsvError(null);
if (!file) {
setCsvText(null);
setCsvRows([]);
setCsvFileName(null);
return;
}
try {
const text = await file.text();
const { rows } = parseBatchCsv(text);
setCsvText(text);
setCsvRows(rows);
setCsvFileName(file.name);
setRowOverrides({});
} catch (err) {
setCsvError(err.message);
setCsvRows([]);
setCsvText(null);
}
};
// Auto-mapping by csvLabel
const autoMappedRows = useMemo(() => {
return csvRows.map((row) => {
const overrides = rowOverrides[row.rowIndex] || {};
const trainerId =
overrides.trainerId !== undefined
? overrides.trainerId
: trainers.find((t) => t.csvLabel?.toLowerCase() === row.characterLabel.toLowerCase())?.id || null;
const studioId =
overrides.studioId !== undefined
? overrides.studioId
: (row.studioLabel
? studios.find((s) => s.csvLabel?.toLowerCase() === row.studioLabel.toLowerCase())?.id || null
: studios[0]?.id || null);
return {
...row,
trainerId,
studioId,
skipped: !!overrides.skipped,
};
});
}, [csvRows, trainers, studios, rowOverrides]);
const issues = useMemo(() => {
const list = [];
autoMappedRows.forEach((r) => {
if (r.skipped) return;
if (!r.trainerId) list.push({ row: r.rowIndex, msg: `Row ${r.rowIndex + 1} (${r.practiceName || '—'}): no trainer match for "${r.characterLabel}"` });
if (!r.studioId) list.push({ row: r.rowIndex, msg: `Row ${r.rowIndex + 1}: no studio match for "${r.studioLabel || '(blank)'}"` });
});
return list;
}, [autoMappedRows]);
const activeRows = autoMappedRows.filter((r) => !r.skipped);
const overrideRow = (rowIndex, patch) => {
setRowOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] || {}), ...patch } }));
};
const bulkAssignTrainer = (trainerId) => {
const next = {};
csvRows.forEach((r) => { next[r.rowIndex] = { ...(rowOverrides[r.rowIndex] || {}), trainerId }; });
setRowOverrides(next);
};
const bulkAssignStudio = (studioId) => {
const next = {};
csvRows.forEach((r) => { next[r.rowIndex] = { ...(rowOverrides[r.rowIndex] || {}), studioId }; });
setRowOverrides(next);
};
const handleEstimate = async (batchId) => {
setEstimating(true);
setEstimateError(null);
try {
const res = await fetch(`/api/batches/${batchId}/estimate-cost`, {
method: 'POST',
headers: { 'x-api-key': apiKey },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Cost estimate failed: ${res.status}`);
setEstimate(data);
} catch (err) {
setEstimateError(err.message);
} finally {
setEstimating(false);
}
};
const handleCreate = async (alsoEstimate) => {
setCreating(true);
setCreateError(null);
setEstimate(null);
setEstimateError(null);
try {
const payload = {
name: name.trim(),
...settings,
jobs: activeRows.map((r) => ({
rowIndex: r.rowIndex,
practiceName: r.practiceName,
trainerId: r.trainerId,
studioId: r.studioId,
prompt: r.prompt,
startPosition: r.startPosition,
cameraAngle: r.cameraAngle,
duration: r.duration ?? settings.duration,
quality: r.quality ?? settings.quality,
aspectRatio: settings.aspectRatio,
})),
};
const res = await fetch('/api/batches', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Create failed: ${res.status}`);
if (alsoEstimate) {
await handleEstimate(data.batch.id);
}
// Stay open showing the new batch id; final "Done" closes.
setStep(3);
// store created id
setCreatedBatch(data.batch);
} catch (err) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
const [createdBatch, setCreatedBatch] = useState(null);
const canGoToStep2 = name.trim() && csvRows.length > 0;
const canGoToStep3 = canGoToStep2 && activeRows.length > 0 && issues.length === 0;
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up p-6">
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl w-full max-w-5xl max-h-[90vh] flex flex-col shadow-2xl">
<header className="flex-shrink-0 px-6 py-4 border-b border-white/[0.04] flex items-center justify-between">
<div>
<h2 className="text-white font-bold text-lg">New batch</h2>
<p className="text-white/40 text-[12px] mt-0.5">
Step {step} of 3 {step === 1 ? 'Upload + settings' : step === 2 ? 'Map rows' : 'Estimate + confirm'}
</p>
</div>
<button onClick={onClose} className="text-white/40 hover:text-white text-2xl leading-none">×</button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-6">
{libraryError && (
<div className="bg-red-500/10 border border-red-500/30 text-red-300 text-[13px] rounded-md px-3 py-2 mb-4">
Could not load Trainers/Studios: {libraryError}
</div>
)}
{step === 1 && (
<Step1
name={name} setName={setName}
settings={settings} setSettings={setSettings}
csvFileName={csvFileName} csvRows={csvRows}
csvError={csvError}
onCsvFile={handleCsvFile}
/>
)}
{step === 2 && (
<Step2
rows={autoMappedRows}
trainers={trainers}
studios={studios}
issues={issues}
onOverride={overrideRow}
onBulkTrainer={bulkAssignTrainer}
onBulkStudio={bulkAssignStudio}
/>
)}
{step === 3 && (
<Step3
createdBatch={createdBatch}
activeRows={activeRows}
estimate={estimate}
estimating={estimating}
estimateError={estimateError}
createError={createError}
creating={creating}
settings={settings}
onEstimate={() => createdBatch && handleEstimate(createdBatch.id)}
onCreate={() => handleCreate(true)}
/>
)}
</div>
<footer className="flex-shrink-0 px-6 py-4 border-t border-white/[0.04] flex items-center justify-between">
<button
onClick={onClose}
className="text-[13px] text-white/40 hover:text-white/80 transition-colors"
>
Cancel
</button>
<div className="flex items-center gap-3">
{step > 1 && (
<button
onClick={() => setStep(step - 1)}
disabled={creating}
className="h-10 px-4 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold border border-white/5 transition-all"
>
Back
</button>
)}
{step === 1 && (
<button
onClick={() => setStep(2)}
disabled={!canGoToStep2}
className="h-10 px-5 rounded-md bg-[#d9ff00] text-black hover:bg-[#e5ff33] text-xs font-semibold transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
Next
</button>
)}
{step === 2 && (
<button
onClick={() => setStep(3)}
disabled={!canGoToStep3}
className="h-10 px-5 rounded-md bg-[#d9ff00] text-black hover:bg-[#e5ff33] text-xs font-semibold transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
Next
</button>
)}
{step === 3 && !createdBatch && (
<button
onClick={() => handleCreate(true)}
disabled={creating}
className="h-10 px-5 rounded-md bg-[#d9ff00] text-black hover:bg-[#e5ff33] text-xs font-semibold transition-all disabled:opacity-30"
>
{creating ? 'Saving…' : 'Save batch + estimate cost'}
</button>
)}
{step === 3 && createdBatch && (
<button
onClick={() => onCreated?.(createdBatch)}
className="h-10 px-5 rounded-md bg-[#d9ff00] text-black hover:bg-[#e5ff33] text-xs font-semibold transition-all"
>
Done
</button>
)}
</div>
</footer>
</div>
</div>
);
}
function Field({ label, children, hint }) {
return (
<div>
<label className="block text-[11px] font-bold text-white/40 uppercase tracking-wide mb-1.5">{label}</label>
{children}
{hint && <p className="text-[11px] text-white/30 mt-1">{hint}</p>}
</div>
);
}
const inputClass =
'w-full bg-white/5 border border-white/[0.03] rounded-md px-3 py-2 text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30 text-[13px]';
function Step1({ name, setName, settings, setSettings, csvFileName, csvRows, csvError, onCsvFile }) {
return (
<div className="space-y-6">
<Field label="Batch name">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Somatic practices — April run"
className={inputClass}
/>
</Field>
<Field label="CSV file" hint="Drop the Rasika-style CSV. We parse on upload and validate columns.">
<input
type="file"
accept=".csv,text/csv"
onChange={(e) => onCsvFile(e.target.files?.[0])}
className="w-full text-[13px] text-white/70 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-white/10 file:text-white/80 file:text-[12px] file:cursor-pointer hover:file:bg-white/20"
/>
{csvFileName && (
<p className="mt-2 text-[12px] text-white/50">
<span className="text-[#d9ff00]"></span> {csvFileName} {csvRows.length} rows parsed
</p>
)}
{csvError && (
<div className="mt-2 bg-red-500/10 border border-red-500/30 text-red-300 text-[13px] rounded-md px-3 py-2">
{csvError}
</div>
)}
</Field>
<div className="border-t border-white/[0.04] pt-6">
<h3 className="text-white/70 text-[13px] font-semibold mb-4">Generation settings (apply to every row)</h3>
<div className="grid grid-cols-2 gap-4">
<Field label="Model" hint="seedance-v2.0-i2v supports 5/10/15 second clips natively.">
<input value={settings.model} disabled className={inputClass + ' opacity-60'} />
</Field>
<Field label="Duration (s)">
<select value={settings.duration} onChange={(e) => setSettings({ ...settings, duration: Number(e.target.value) })} className={inputClass}>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={15}>15</option>
</select>
</Field>
<Field label="Quality">
<select value={settings.quality} onChange={(e) => setSettings({ ...settings, quality: e.target.value })} className={inputClass}>
<option value="basic">basic</option>
<option value="high">high</option>
</select>
</Field>
<Field label="Aspect ratio">
<select value={settings.aspectRatio} onChange={(e) => setSettings({ ...settings, aspectRatio: e.target.value })} className={inputClass}>
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="4:3">4:3</option>
<option value="3:4">3:4</option>
</select>
</Field>
<Field label="Concurrency" hint="How many MuAPI jobs run in parallel. Worker honours this.">
<input
type="number"
min={1}
max={20}
value={settings.concurrency}
onChange={(e) => setSettings({ ...settings, concurrency: Math.max(1, Math.min(20, Number(e.target.value) || 1)) })}
className={inputClass}
/>
</Field>
</div>
</div>
</div>
);
}
function Step2({ rows, trainers, studios, issues, onOverride, onBulkTrainer, onBulkStudio }) {
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-end justify-between">
<p className="text-[13px] text-white/60">
{rows.length} rows · {rows.filter((r) => !r.skipped).length} active · {issues.length} issue(s)
</p>
<div className="flex gap-2">
<select
onChange={(e) => e.target.value && onBulkTrainer(e.target.value)}
defaultValue=""
className="bg-white/5 border border-white/[0.03] rounded-md px-2 py-1.5 text-white text-[12px]"
>
<option value="" disabled>Bulk: set trainer</option>
{trainers.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<select
onChange={(e) => e.target.value && onBulkStudio(e.target.value)}
defaultValue=""
className="bg-white/5 border border-white/[0.03] rounded-md px-2 py-1.5 text-white text-[12px]"
>
<option value="" disabled>Bulk: set studio</option>
{studios.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
</div>
{issues.length > 0 && (
<div className="bg-red-500/5 border border-red-500/20 rounded-md p-3 max-h-32 overflow-y-auto text-[12px] text-red-300 space-y-0.5">
{issues.slice(0, 8).map((i) => <div key={i.row + i.msg}> {i.msg}</div>)}
{issues.length > 8 && <div className="text-red-400/60">and {issues.length - 8} more</div>}
</div>
)}
<div className="border border-white/[0.04] rounded-md overflow-hidden">
<table className="w-full text-[12px]">
<thead className="bg-white/[0.02] text-white/40 text-[11px] uppercase tracking-wide">
<tr>
<th className="text-left px-3 py-2 w-10">#</th>
<th className="text-left px-3 py-2">Practice</th>
<th className="text-left px-3 py-2">CSV character</th>
<th className="text-left px-3 py-2">Trainer</th>
<th className="text-left px-3 py-2">Studio</th>
<th className="text-center px-3 py-2 w-16">Skip</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.rowIndex} className={`border-t border-white/[0.03] ${r.skipped ? 'opacity-30' : ''}`}>
<td className="px-3 py-2 text-white/40">{r.rowIndex + 1}</td>
<td className="px-3 py-2 text-white/90">{r.practiceName}</td>
<td className="px-3 py-2 text-white/40">{r.characterLabel}</td>
<td className="px-3 py-2">
<select
value={r.trainerId || ''}
onChange={(e) => onOverride(r.rowIndex, { trainerId: e.target.value || null })}
className={`w-full bg-white/5 border rounded px-2 py-1 text-[12px] ${r.trainerId ? 'border-white/[0.04] text-white/90' : 'border-red-500/40 text-red-300'}`}
>
<option value=""> pick </option>
{trainers.map((t) => (
<option key={t.id} value={t.id}>
{t.name}{t.csvLabel ? ` (${t.csvLabel})` : ''}
</option>
))}
</select>
</td>
<td className="px-3 py-2">
<select
value={r.studioId || ''}
onChange={(e) => onOverride(r.rowIndex, { studioId: e.target.value || null })}
className={`w-full bg-white/5 border rounded px-2 py-1 text-[12px] ${r.studioId ? 'border-white/[0.04] text-white/90' : 'border-red-500/40 text-red-300'}`}
>
<option value=""> pick </option>
{studios.map((s) => (
<option key={s.id} value={s.id}>
{s.name}{s.csvLabel ? ` (${s.csvLabel})` : ''}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={r.skipped}
onChange={(e) => onOverride(r.rowIndex, { skipped: e.target.checked })}
className="accent-[#d9ff00]"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function Step3({ createdBatch, activeRows, estimate, estimating, estimateError, createError, creating, settings, onEstimate, onCreate }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<Stat label="Rows to generate" value={activeRows.length} />
<Stat label="Duration per clip" value={`${settings.duration}s`} />
<Stat label="Quality" value={settings.quality} />
<Stat label="Concurrency" value={`${settings.concurrency} parallel`} />
</div>
<div className="bg-white/[0.02] border border-white/[0.04] rounded-md p-5">
<p className="text-white/40 text-[12px] uppercase tracking-wide mb-2">Estimated cost</p>
{estimate ? (
<div>
<div className="text-2xl font-bold text-[#d9ff00]">
{estimate.currency} {(estimate.total).toFixed(2)}
</div>
<p className="text-[12px] text-white/50 mt-1">
{estimate.currency} {(estimate.perJob).toFixed(4)}/clip × {estimate.rows} rows
</p>
</div>
) : estimating ? (
<p className="text-white/40 text-sm">Calling MuAPI</p>
) : estimateError ? (
<div className="bg-red-500/10 border border-red-500/30 text-red-300 text-[12px] rounded-md px-3 py-2">{estimateError}</div>
) : createdBatch ? (
<button
onClick={onEstimate}
className="bg-white/5 border border-white/[0.04] rounded-md px-3 py-1.5 text-[12px] text-white/80 hover:bg-white/10"
>
Recalculate
</button>
) : (
<p className="text-white/30 text-sm">Save the batch first to fetch the live cost.</p>
)}
</div>
{createError && (
<div className="bg-red-500/10 border border-red-500/30 text-red-300 text-[13px] rounded-md px-3 py-2">{createError}</div>
)}
{createdBatch && (
<div className="bg-[#d9ff00]/5 border border-[#d9ff00]/20 rounded-md p-4 text-[13px] text-white/80">
Batch saved as draft (id <code className="text-[#d9ff00]">{createdBatch.id}</code>). The worker container will pick up its jobs once <code>feat/batch-worker</code> ships in the next PR.
</div>
)}
</div>
);
}
function Stat({ label, value }) {
return (
<div className="bg-white/[0.02] border border-white/[0.04] rounded-md p-4">
<p className="text-white/40 text-[11px] uppercase tracking-wide mb-1">{label}</p>
<p className="text-white text-base font-semibold">{value}</p>
</div>
);
}

90
lib/csvParser.js Normal file
View file

@ -0,0 +1,90 @@
import Papa from 'papaparse';
// Maps the Rasika-style somatic-practice CSV into our Job shape.
// Expected columns (case-insensitive, trimmed):
// "Video Generation Model", "Character", "Practice Name",
// "Practice Description", "Start Position", "Time (Duration)",
// "Studio", "Camera Angle", "Video Quality", "Status"
//
// Only Character, Practice Name, and Practice Description are required.
const REQUIRED_COLUMNS = ['Character', 'Practice Name', 'Practice Description'];
export function parseBatchCsv(text) {
const result = Papa.parse(text, {
header: true,
skipEmptyLines: true,
transformHeader: (h) => h.trim(),
});
if (result.errors && result.errors.length > 0) {
const first = result.errors[0];
throw new Error(`CSV parse error at row ${first.row}: ${first.message}`);
}
const headers = result.meta.fields || [];
const missing = REQUIRED_COLUMNS.filter(
(col) => !headers.some((h) => h.toLowerCase() === col.toLowerCase()),
);
if (missing.length > 0) {
throw new Error(`Missing required column(s): ${missing.join(', ')}`);
}
const rows = result.data.map((raw, idx) => normaliseRow(raw, idx));
return { rows, headers };
}
function normaliseRow(raw, idx) {
const get = (name) => {
const key = Object.keys(raw).find((k) => k.toLowerCase() === name.toLowerCase());
return key ? (raw[key] ?? '').toString().trim() : '';
};
const character = get('Character');
const practiceName = get('Practice Name');
const description = get('Practice Description');
const startPosition = get('Start Position');
const cameraAngle = get('Camera Angle');
const studio = get('Studio');
const timeStr = get('Time (Duration)');
const qualityStr = get('Video Quality');
return {
rowIndex: idx,
practiceName,
characterLabel: character,
studioLabel: studio || null,
prompt: composePrompt({ description, startPosition, cameraAngle }),
rawDescription: description,
startPosition: startPosition || null,
cameraAngle: cameraAngle || null,
duration: parseDuration(timeStr),
quality: parseQuality(qualityStr),
};
}
function composePrompt({ description, startPosition, cameraAngle }) {
const parts = [];
if (description) parts.push(description);
if (startPosition) parts.push(`Start position: ${startPosition}.`);
if (cameraAngle) parts.push(`Camera: ${cameraAngle}.`);
return parts.join(' ');
}
function parseDuration(s) {
if (!s) return 15;
const m = s.match(/(\d+)/);
if (!m) return 15;
const n = parseInt(m[1], 10);
// Seedance 2.0 i2v supports 5, 10, 15. Snap to nearest valid.
if (n <= 5) return 5;
if (n <= 10) return 10;
return 15;
}
function parseQuality(s) {
if (!s) return 'basic';
const lower = s.toLowerCase();
if (lower.includes('1080') || lower === 'high') return 'high';
return 'basic';
}

7
package-lock.json generated
View file

@ -17,6 +17,7 @@
"ai-agent": "file:./packages/ai-agent",
"axios": "^1.7.0",
"next": "^15.0.0",
"papaparse": "^5.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
@ -13247,6 +13248,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",

View file

@ -91,6 +91,7 @@
"ai-agent": "file:./packages/ai-agent",
"axios": "^1.7.0",
"next": "^15.0.0",
"papaparse": "^5.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",