mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
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:
parent
404edc7f1e
commit
694c7075e2
9 changed files with 931 additions and 10 deletions
73
app/api/batches/[id]/estimate-cost/route.js
Normal file
73
app/api/batches/[id]/estimate-cost/route.js
Normal 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,
|
||||
});
|
||||
}
|
||||
22
app/api/batches/[id]/route.js
Normal file
22
app/api/batches/[id]/route.js
Normal 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
86
app/api/batches/route.js
Normal 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 });
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
556
components/batch/NewBatchWizard.jsx
Normal file
556
components/batch/NewBatchWizard.jsx
Normal 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
90
lib/csvParser.js
Normal 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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue