mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
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).
90 lines
2.8 KiB
JavaScript
90 lines
2.8 KiB
JavaScript
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';
|
|
}
|