mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
feat: background worker, prompt template, section switcher, home hub
Bundles three changes that were originally landed as stacked PRs into peer branches and never reached main. Combined here as a single PR targeting main. worker/index.js (new) --------------------- Long-lived Node process that: - Polls Postgres every 2s for batches in status='running'. - Claims queued jobs with SELECT ... FOR UPDATE SKIP LOCKED (single worker today, ready for N-worker scale tomorrow with no further code change). - Re-uploads local-only trainer images (/api/uploads/...) to MuAPI's /upload_file once and caches the resulting CDN URL on the trainer row so future jobs reuse it. - Submits POST /api/v1/<batch.model> with prompt, images_list, aspect_ratio, duration, quality. - Polls /api/v1/predictions/:id/result, extracts videoUrl from outputs[0]/url/output.url/video_url on success. - Backoff: min(10 * 3^retries, 300)s. After retries >= 3, status= 'failed', waiting for manual Retry from the UI. - Recovery on boot: re-queues anything left in 'submitting' from a crashed run; 'polling' jobs keep their muapiRequestId. Configuration: MUAPI_API_KEY (required to do work), MUAPI_BASE_URL (default https://api.muapi.ai), WORKER_TICK_MS (default 2000), UPLOAD_DIR (default /data/uploads). worker/package.json: { "type": "module" } so plain Node 20 runs the ESM imports. Dockerfile: copy /app/worker into the runner stage. docker-compose.override.yml: replace placeholder with the real 'npx prisma generate && node worker/index.js'. lib/promptTemplate.js (new) + worker integration ------------------------------------------------ renderPrompt({trainer, studio, job}) wraps each row's raw practice description in a fixed narrative template that pins identity, biomechanics, environment, lighting, clothing, expression, video style, and a list of forbidden behaviours (no camera movement, no cuts, no limb warping, no pose distortion, etc). duration, aspect_ratio, and quality remain SEPARATE fields in the MuAPI request body; they are never injected into the prompt text. lib/csvParser.js: stops appending 'Start position: ... Camera: ...' into the stored prompt. Just stores the raw practice description; startPosition + cameraAngle remain available as their own columns for the renderer to consume. components/SectionSwitcher.jsx (new) ------------------------------------ Two-pill switcher (Studio · Batch) rendered in the top-left of every shell. Active pill is yellow on black. Click writes the choice to localStorage so the home hub can highlight 'Last used'. StandaloneShell, BatchShell, BatchDetail headers all updated to host the switcher next to the logo. The redundant '← Studio' link in BatchShell's right-side actions is removed since the switcher replaces it. app/page.js + components/HomeHub.jsx ------------------------------------ / used to redirect to /studio. Now serves a hub with two cards: Batch (CSV-driven automation) and Studio (one-off generations). The card the user last opened gets a yellow accent + 'Last used' tag from localStorage. Stub-package copy ----------------- packages/ai-agent and packages/workflow-ui description strings and runtime 'feature unavailable' notices: removed organization-specific phrasing in favour of generic 'this fork' wording.
This commit is contained in:
parent
89dd7c3643
commit
24e4bb40e5
17 changed files with 633 additions and 38 deletions
|
|
@ -32,6 +32,7 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/lib ./lib
|
||||
COPY --from=builder /app/worker ./worker
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
import HomeHub from '@/components/HomeHub';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Open Generative AI',
|
||||
description: 'Pick a workspace — Studio for one-off creations, Batch for CSV-driven automation.',
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
redirect('/studio');
|
||||
return <HomeHub />;
|
||||
}
|
||||
|
|
|
|||
138
components/HomeHub.jsx
Normal file
138
components/HomeHub.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
id: 'batch',
|
||||
title: 'Batch',
|
||||
tagline: 'CSV in. 255 videos out.',
|
||||
description:
|
||||
'Upload a CSV, map trainers and studios once, hit Start, and a background worker submits every row to MuAPI seedance-v2.0-i2v with retries, pause/resume, and per-row results.',
|
||||
href: '/batch',
|
||||
accent: 'bg-[#d9ff00] text-black hover:bg-[#e5ff33]',
|
||||
badge: 'CSV-driven automation',
|
||||
icon: (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
bullets: ['CSV upload + auto-mapping', 'Live progress + retry', 'Results CSV export'],
|
||||
},
|
||||
{
|
||||
id: 'studio',
|
||||
title: 'Studio',
|
||||
tagline: 'One generation at a time.',
|
||||
description:
|
||||
'200+ MuAPI models for ad-hoc image, video, lip-sync, and cinema generations. Use this when you want to experiment with a single prompt rather than run a CSV.',
|
||||
href: '/studio',
|
||||
accent: 'bg-white/10 text-white hover:bg-white/20 border border-white/20',
|
||||
badge: 'For one-offs and exploration',
|
||||
icon: (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
),
|
||||
bullets: ['Image / Video / Lip Sync / Cinema', 'Bring-your-own MuAPI key', 'Live preview + history'],
|
||||
},
|
||||
];
|
||||
|
||||
const LAST_KEY = 'lastSection';
|
||||
|
||||
export default function HomeHub() {
|
||||
const [lastSection, setLastSection] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setLastSection(localStorage.getItem(LAST_KEY));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#030303] text-white flex flex-col">
|
||||
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center px-6 bg-black/20 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">OpenGenerativeAI</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex items-center justify-center px-6 py-12">
|
||||
<div className="max-w-5xl w-full">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Pick a workspace</h1>
|
||||
<p className="text-white/50 text-sm mt-2">
|
||||
Use Batch for the CSV pipeline. Use Studio for ad-hoc generations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{CARDS.map((c) => {
|
||||
const isLast = lastSection === c.id;
|
||||
return (
|
||||
<a
|
||||
key={c.id}
|
||||
href={c.href}
|
||||
onClick={() => {
|
||||
try { localStorage.setItem(LAST_KEY, c.id); } catch {}
|
||||
}}
|
||||
className={`group relative bg-[#0a0a0a] border rounded-xl p-7 flex flex-col transition-all hover:translate-y-[-2px] hover:border-white/15 ${
|
||||
isLast ? 'border-[#d9ff00]/40 ring-1 ring-[#d9ff00]/20' : 'border-white/[0.05]'
|
||||
}`}
|
||||
>
|
||||
{isLast && (
|
||||
<span className="absolute top-3 right-3 text-[10px] uppercase tracking-wide font-bold text-[#d9ff00]">
|
||||
Last used
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
c.id === 'batch' ? 'bg-[#d9ff00]/10 text-[#d9ff00]' : 'bg-white/5 text-white/80'
|
||||
}`}>
|
||||
{c.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-white/40 font-semibold">{c.badge}</p>
|
||||
<h2 className="text-2xl font-bold mt-0.5">{c.title}</h2>
|
||||
<p className="text-white/60 text-sm">{c.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white/50 text-[13px] leading-relaxed mb-5">{c.description}</p>
|
||||
|
||||
<ul className="space-y-1.5 mb-6">
|
||||
{c.bullets.map((b) => (
|
||||
<li key={b} className="text-[12px] text-white/60 flex items-start gap-2">
|
||||
<span className="text-[#d9ff00] mt-0.5">·</span>
|
||||
<span>{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
<span className={`inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold transition-all ${c.accent}`}>
|
||||
Open {c.title} →
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] text-white/30 mt-8">
|
||||
<a href="/batch" className="hover:text-white/60">/batch</a>{' '}·{' '}
|
||||
<a href="/studio" className="hover:text-white/60">/studio</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
components/SectionSwitcher.jsx
Normal file
46
components/SectionSwitcher.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
// Two-pill switcher between the Studio (image/video/etc) and the Batch
|
||||
// (CSV-driven automation) sections. Rendered in the header of every
|
||||
// shell so wherever you are, you can jump to the other side.
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'studio', label: 'Studio', href: '/studio' },
|
||||
{ id: 'batch', label: 'Batch', href: '/batch' },
|
||||
];
|
||||
|
||||
export default function SectionSwitcher({ active }) {
|
||||
return (
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Section"
|
||||
className="flex items-center gap-0.5 bg-white/5 border border-white/[0.04] rounded-md p-0.5"
|
||||
>
|
||||
{SECTIONS.map((s) => {
|
||||
const isActive = active === s.id;
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
aria-selected={isActive}
|
||||
role="tab"
|
||||
className={`px-3 py-1 rounded text-[11px] font-semibold uppercase tracking-wide transition-colors ${
|
||||
isActive
|
||||
? 'bg-[#d9ff00] text-black'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('lastSection', s.id);
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
|
|||
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, WorkflowStudio, AgentStudio, getUserBalance } from 'studio';
|
||||
import axios from 'axios';
|
||||
import ApiKeyModal from './ApiKeyModal';
|
||||
import SectionSwitcher from './SectionSwitcher';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'image', label: 'Image Studio' },
|
||||
|
|
@ -240,14 +241,17 @@ export default function StandaloneShell() {
|
|||
{/* Header */}
|
||||
{isHeaderVisible && (
|
||||
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
|
||||
{/* Left: Logo + section switcher */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity" title="Home">
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
|
||||
</a>
|
||||
<SectionSwitcher active="studio" />
|
||||
</div>
|
||||
|
||||
{/* Center: Navigation */}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default function AddAssetModal({ apiKey, endpoint, label, onClose, onCrea
|
|||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={label === 'Trainer' ? 'e.g. Raj' : 'e.g. Ahoum studio'}
|
||||
placeholder={label === 'Trainer' ? 'e.g. Raj' : 'e.g. Main studio'}
|
||||
className="w-full bg-white/5 border border-white/[0.03] rounded-md px-4 py-2 text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30"
|
||||
required
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import SectionSwitcher from '@/components/SectionSwitcher';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
draft: 'bg-white/5 text-white/60 border-white/10',
|
||||
|
|
@ -115,6 +116,8 @@ export default function BatchDetail({ batchId, apiKey }) {
|
|||
{/* Top header */}
|
||||
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<SectionSwitcher active="batch" />
|
||||
<span className="text-white/20">·</span>
|
||||
<a href="/batch" className="text-white/40 hover:text-white text-[12px]">← Back to batches</a>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="text-sm font-bold tracking-tight">{batch.name}</span>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ApiKeyModal from '@/components/ApiKeyModal';
|
||||
import SectionSwitcher from '@/components/SectionSwitcher';
|
||||
import TrainersTab from './TrainersTab';
|
||||
import StudiosTab from './StudiosTab';
|
||||
import BatchesTab from './BatchesTab';
|
||||
|
|
@ -56,13 +57,15 @@ export default function BatchShell() {
|
|||
<div className="min-h-screen bg-[#030303] text-white flex flex-col">
|
||||
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">Batch</span>
|
||||
<span className="text-white/30 text-sm">/ Open Generative AI</span>
|
||||
<a href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity" title="Home">
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
|
||||
</a>
|
||||
<SectionSwitcher active="batch" />
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-6">
|
||||
|
|
@ -83,12 +86,6 @@ export default function BatchShell() {
|
|||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/studio"
|
||||
className="text-[12px] text-white/50 hover:text-white/80 transition-colors"
|
||||
>
|
||||
← Studio
|
||||
</a>
|
||||
<button
|
||||
onClick={handleKeyChange}
|
||||
className="text-[11px] text-white/40 hover:text-red-400 transition-colors"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ services:
|
|||
|
||||
worker:
|
||||
user: root
|
||||
command: ["sh", "-c", "echo 'worker placeholder — replaced in slice 4'; tail -f /dev/null"]
|
||||
command: ["sh", "-c", "npx prisma generate && node worker/index.js"]
|
||||
volumes:
|
||||
- ./:/app
|
||||
- worker_node_modules:/app/node_modules
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ function normaliseRow(raw, idx) {
|
|||
practiceName,
|
||||
characterLabel: character,
|
||||
studioLabel: studio || null,
|
||||
prompt: composePrompt({ description, startPosition, cameraAngle }),
|
||||
// Worker renders the full template (lib/promptTemplate.js) at submit
|
||||
// time using trainer, studio, and the structured fields below. We just
|
||||
// store the raw practice description here so the prompt-builder has
|
||||
// clean inputs to work with.
|
||||
prompt: description,
|
||||
rawDescription: description,
|
||||
startPosition: startPosition || null,
|
||||
cameraAngle: cameraAngle || null,
|
||||
|
|
@ -63,14 +67,6 @@ function normaliseRow(raw, idx) {
|
|||
};
|
||||
}
|
||||
|
||||
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+)/);
|
||||
|
|
|
|||
78
lib/promptTemplate.js
Normal file
78
lib/promptTemplate.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Hard-coded prompt template for the seedance-v2.0-i2v batch generations.
|
||||
// Duration, aspect_ratio, and quality are intentionally NOT in the prompt —
|
||||
// they're separate fields in the MuAPI request body.
|
||||
//
|
||||
// Inputs:
|
||||
// trainer — { name, csvLabel, ... } from the trainers table
|
||||
// studio — { name, csvLabel, ... } from the studios table (may be null)
|
||||
// job — { practiceName, prompt (raw description),
|
||||
// startPosition, cameraAngle }
|
||||
//
|
||||
// Returns a single multi-line string ready to drop into payload.prompt.
|
||||
|
||||
export function renderPrompt({ trainer, studio, job }) {
|
||||
const trainerName = (trainer?.name || trainer?.csvLabel || 'the trainer').trim();
|
||||
const studioName = (studio?.name || studio?.csvLabel || 'the studio').trim();
|
||||
const practice = job?.practiceName?.trim() || 'the practice';
|
||||
const camera = job?.cameraAngle?.trim() || 'eye-level tripod shot';
|
||||
const startPosition = job?.startPosition?.trim() || 'a relaxed neutral standing position';
|
||||
const description = job?.prompt?.trim() || '';
|
||||
|
||||
return [
|
||||
`Single continuous take of @${trainerName} performing ${practice} inside @${studioName}.`,
|
||||
`Camera: ${camera}, static tripod shot, no camera movement.`,
|
||||
`@${trainerName} starts in ${startPosition}.`,
|
||||
``,
|
||||
`Step-by-step movement:`,
|
||||
description,
|
||||
``,
|
||||
`Movement style:`,
|
||||
`- Controlled and instructional`,
|
||||
`- clearly demonstrates correct form`,
|
||||
`- no abrupt or unnatural motion`,
|
||||
``,
|
||||
`Biomechanics constraints:`,
|
||||
`- correct joint alignment at all times`,
|
||||
`- natural range of motion (no overextension)`,
|
||||
`- stable base and grounded contact with floor`,
|
||||
`- balanced weight distribution`,
|
||||
`- spine remains neutral unless specified`,
|
||||
`Movement must follow real human biomechanics, no artificial motion.`,
|
||||
``,
|
||||
`Identity constraints:`,
|
||||
`- @${trainerName} must remain 100% consistent (face, body, proportions)`,
|
||||
`- no identity drift`,
|
||||
``,
|
||||
`Environment constraints:`,
|
||||
`- keep studio layout exactly unchanged`,
|
||||
`- maintain plant positions, lighting, textures`,
|
||||
`- yoga mat remains centered and stable`,
|
||||
``,
|
||||
`Lighting:`,
|
||||
`- soft natural daylight (from studio windows)`,
|
||||
`- stable shadows`,
|
||||
`- no flicker or exposure shifts`,
|
||||
``,
|
||||
`Clothing:`,
|
||||
`- off-white / beige relaxed yoga wear`,
|
||||
`- natural fabric, no logos`,
|
||||
``,
|
||||
`Expression:`,
|
||||
`- calm, focused, slightly warm and approachable`,
|
||||
``,
|
||||
`Video style:`,
|
||||
`- real-world recording`,
|
||||
`- no stylization`,
|
||||
`- no cinematic effects`,
|
||||
`- no artificial smoothing`,
|
||||
``,
|
||||
`Constraints:`,
|
||||
`- no camera movement`,
|
||||
`- no cuts or transitions`,
|
||||
`- no limb warping`,
|
||||
`- no pose distortion`,
|
||||
`- no speed ramping`,
|
||||
`- no background changes`,
|
||||
`- no added props`,
|
||||
].join('\n');
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ai-agent",
|
||||
"version": "0.0.0-stub",
|
||||
"description": "Local stub. Upstream submodule (jaiprasad04/ai-agent) is unavailable. Provides no-op exports so the build succeeds; the /agents routes will render a 'feature unavailable' notice at runtime.",
|
||||
"description": "Local stub. Upstream submodule is unavailable. Provides no-op exports so the build succeeds; the /agents routes render a 'feature unavailable' notice at runtime.",
|
||||
"main": "src/index.js",
|
||||
"module": "src/index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const Unavailable = ({ feature }) =>
|
|||
React.createElement(
|
||||
'p',
|
||||
{ style: { color: '#ffffff80', maxWidth: 480 } },
|
||||
`The ai-agent package is not bundled with this Ahoum-Dev fork. The /agents/${feature} route is a no-op. See packages/ai-agent/package.json for context.`
|
||||
`The ai-agent package is not bundled in this fork. The /agents/${feature} route is a no-op. See packages/ai-agent/package.json for context.`
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "workflow-builder",
|
||||
"version": "0.0.0-stub",
|
||||
"description": "Local stub. Upstream submodule (Anil-matcha/workflow-ui) is unavailable. Provides no-op exports so the build succeeds; the Workflows tab will render a 'feature unavailable' notice at runtime.",
|
||||
"description": "Local stub. Upstream submodule is unavailable. Provides no-op exports so the build succeeds; the Workflows tab renders a 'feature unavailable' notice at runtime.",
|
||||
"main": "src/index.js",
|
||||
"module": "src/index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,6 @@ export const WorkflowBuilder = () =>
|
|||
React.createElement(
|
||||
'p',
|
||||
{ style: { color: '#ffffff80', maxWidth: 480 } },
|
||||
'The workflow-builder package is not bundled with this Ahoum-Dev fork. The Workflows tab is a no-op. See packages/workflow-ui/package.json for context.'
|
||||
'The workflow-builder package is not bundled in this fork. The Workflows tab is a no-op. See packages/workflow-ui/package.json for context.'
|
||||
)
|
||||
);
|
||||
|
|
|
|||
324
worker/index.js
Normal file
324
worker/index.js
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
// Long-lived worker process. Polls Postgres for queued jobs, submits
|
||||
// them to MuAPI's seedance-v2.0-i2v endpoint, polls for results, applies
|
||||
// retry/backoff on failure, and updates batch counters.
|
||||
//
|
||||
// Run inside the `worker` docker-compose service. One process per
|
||||
// deployment is enough for typical batch volumes; the claim query
|
||||
// uses FOR UPDATE SKIP LOCKED so scaling to N workers is a one-line
|
||||
// change later.
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { renderPrompt } from '../lib/promptTemplate.js';
|
||||
|
||||
const prisma = new PrismaClient({ log: ['error'] });
|
||||
|
||||
const MUAPI_BASE = process.env.MUAPI_BASE_URL || 'https://api.muapi.ai';
|
||||
const API_KEY = process.env.MUAPI_API_KEY || '';
|
||||
const TICK_MS = parseInt(process.env.WORKER_TICK_MS || '2000', 10);
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
const TERMINAL_OK = new Set(['completed', 'succeeded', 'success']);
|
||||
const TERMINAL_FAIL = new Set(['failed', 'error']);
|
||||
|
||||
let stopping = false;
|
||||
process.on('SIGINT', () => { stopping = true; });
|
||||
process.on('SIGTERM', () => { stopping = true; });
|
||||
|
||||
log('starting', {
|
||||
base: MUAPI_BASE,
|
||||
hasApiKey: !!API_KEY,
|
||||
tickMs: TICK_MS,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
});
|
||||
|
||||
await recoverOrphans();
|
||||
|
||||
while (!stopping) {
|
||||
try {
|
||||
await tick();
|
||||
} catch (err) {
|
||||
log('tick.error', { err: err.message });
|
||||
}
|
||||
await sleep(TICK_MS);
|
||||
}
|
||||
|
||||
log('stopping');
|
||||
await prisma.$disconnect();
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
async function tick() {
|
||||
if (!API_KEY) return; // can't do anything useful without a key
|
||||
|
||||
const batches = await prisma.batch.findMany({
|
||||
where: { status: 'running' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const batch of batches) {
|
||||
await advanceBatch(batch);
|
||||
}
|
||||
|
||||
// Polling jobs may belong to running OR paused batches (we let in-flight
|
||||
// calls finish even on pause to avoid wasting credits).
|
||||
await pollPending();
|
||||
}
|
||||
|
||||
async function advanceBatch(batch) {
|
||||
// Count in-flight slots
|
||||
const inflight = await prisma.job.count({
|
||||
where: { batchId: batch.id, status: { in: ['submitting', 'polling'] } },
|
||||
});
|
||||
const slots = batch.concurrency - inflight;
|
||||
if (slots <= 0) return;
|
||||
|
||||
// Claim queued jobs atomically with SKIP LOCKED
|
||||
const ids = await claimJobs(batch.id, slots);
|
||||
if (ids.length === 0) {
|
||||
await maybeMarkCompleted(batch);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(ids.map(submitJob));
|
||||
}
|
||||
|
||||
async function claimJobs(batchId, slots) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw`
|
||||
SELECT id FROM jobs
|
||||
WHERE batch_id = ${batchId}
|
||||
AND status = 'queued'
|
||||
AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())
|
||||
ORDER BY row_index ASC
|
||||
LIMIT ${slots}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
if (ids.length === 0) return [];
|
||||
await tx.job.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { status: 'submitting', startedAt: new Date(), error: null },
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitJob(jobId) {
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id: jobId },
|
||||
include: {
|
||||
trainer: true,
|
||||
studio: true,
|
||||
batch: { select: { model: true } },
|
||||
},
|
||||
});
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
if (!job.trainer) throw new Error('Job has no trainer');
|
||||
const trainerCdnUrl = await ensureMuapiUrl('trainer', job.trainer);
|
||||
|
||||
const fullPrompt = renderPrompt({
|
||||
trainer: job.trainer,
|
||||
studio: job.studio,
|
||||
job,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
prompt: fullPrompt,
|
||||
images_list: [trainerCdnUrl],
|
||||
aspect_ratio: job.aspectRatio,
|
||||
duration: job.duration,
|
||||
quality: job.quality,
|
||||
};
|
||||
|
||||
const submitRes = await fetch(`${MUAPI_BASE}/api/v1/${job.batch.model}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!submitRes.ok) {
|
||||
const text = await submitRes.text().catch(() => '');
|
||||
throw new Error(`MuAPI ${submitRes.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await submitRes.json();
|
||||
const requestId = data.request_id || data.id;
|
||||
if (!requestId) throw new Error(`No request_id in response: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: job.id },
|
||||
data: { status: 'polling', muapiRequestId: requestId },
|
||||
});
|
||||
log('submit.ok', { jobId: job.id, requestId });
|
||||
} catch (err) {
|
||||
log('submit.fail', { jobId: job.id, err: err.message });
|
||||
await markFailureWithBackoff(job.id, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollPending() {
|
||||
const polling = await prisma.job.findMany({
|
||||
where: { status: 'polling', muapiRequestId: { not: null } },
|
||||
take: 50,
|
||||
});
|
||||
await Promise.all(polling.map(pollJob));
|
||||
}
|
||||
|
||||
async function pollJob(job) {
|
||||
try {
|
||||
const res = await fetch(`${MUAPI_BASE}/api/v1/predictions/${job.muapiRequestId}/result`, {
|
||||
headers: { 'x-api-key': API_KEY },
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 5xx — transient, leave for next tick. 4xx — fail with backoff.
|
||||
if (res.status >= 500) return;
|
||||
const text = await res.text().catch(() => '');
|
||||
await markFailureWithBackoff(job.id, `Poll ${res.status}: ${text.slice(0, 200)}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const status = data.status?.toLowerCase();
|
||||
if (TERMINAL_OK.has(status)) {
|
||||
const videoUrl = data.outputs?.[0] || data.url || data.output?.url || data.video_url || null;
|
||||
await prisma.$transaction([
|
||||
prisma.job.update({
|
||||
where: { id: job.id },
|
||||
data: { status: 'done', videoUrl, completedAt: new Date() },
|
||||
}),
|
||||
prisma.batch.update({
|
||||
where: { id: job.batchId },
|
||||
data: { done: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
log('poll.done', { jobId: job.id, videoUrl });
|
||||
return;
|
||||
}
|
||||
if (TERMINAL_FAIL.has(status)) {
|
||||
await markFailureWithBackoff(job.id, data.error || `MuAPI status: ${status}`);
|
||||
return;
|
||||
}
|
||||
// pending / in_progress — leave it
|
||||
} catch (err) {
|
||||
log('poll.error', { jobId: job.id, err: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function markFailureWithBackoff(jobId, errorMsg) {
|
||||
const job = await prisma.job.findUnique({ where: { id: jobId } });
|
||||
if (!job) return;
|
||||
|
||||
const nextRetries = job.retries + 1;
|
||||
if (nextRetries > 3) {
|
||||
await prisma.$transaction([
|
||||
prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
retries: nextRetries,
|
||||
error: errorMsg.slice(0, 1000),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.batch.update({
|
||||
where: { id: job.batchId },
|
||||
data: { failed: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
log('job.failed', { jobId, retries: nextRetries });
|
||||
return;
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(10 * Math.pow(3, job.retries), 300) * 1000;
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'queued',
|
||||
retries: nextRetries,
|
||||
error: errorMsg.slice(0, 1000),
|
||||
nextAttemptAt: new Date(Date.now() + backoffMs),
|
||||
muapiRequestId: null,
|
||||
},
|
||||
});
|
||||
log('job.retry', { jobId, retries: nextRetries, backoffMs });
|
||||
}
|
||||
|
||||
async function maybeMarkCompleted(batch) {
|
||||
const counts = await prisma.job.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchId: batch.id },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const map = Object.fromEntries(counts.map((c) => [c.status, c._count._all]));
|
||||
const finished = (map.done || 0) + (map.failed || 0) + (map.cancelled || 0);
|
||||
if (finished >= batch.total) {
|
||||
await prisma.batch.update({
|
||||
where: { id: batch.id },
|
||||
data: { status: 'completed' },
|
||||
});
|
||||
log('batch.completed', { batchId: batch.id });
|
||||
}
|
||||
}
|
||||
|
||||
// If the imageUrl is local (/api/uploads/...), upload the file to MuAPI
|
||||
// and persist the resulting CDN URL so we only pay the round-trip once.
|
||||
async function ensureMuapiUrl(kind, asset) {
|
||||
const url = asset.imageUrl || '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
|
||||
// Local URL — re-upload from disk to MuAPI.
|
||||
const fileName = url.split('/').pop();
|
||||
if (!fileName) throw new Error(`Invalid asset url: ${url}`);
|
||||
const folder = kind === 'trainer' ? 'trainers' : 'studios';
|
||||
const filePath = path.join(UPLOAD_DIR, folder, decodeURIComponent(fileName));
|
||||
const buf = await readFile(filePath);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([buf]), fileName);
|
||||
|
||||
const res = await fetch(`${MUAPI_BASE}/api/v1/upload_file`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': API_KEY },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`MuAPI upload_file ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const cdnUrl = data.url || data.file_url || data?.data?.url;
|
||||
if (!cdnUrl) throw new Error('MuAPI upload returned no URL');
|
||||
|
||||
// Persist back to DB so we don't re-upload for future jobs.
|
||||
if (kind === 'trainer') {
|
||||
await prisma.trainer.update({ where: { id: asset.id }, data: { imageUrl: cdnUrl } });
|
||||
} else {
|
||||
await prisma.studio.update({ where: { id: asset.id }, data: { imageUrl: cdnUrl } });
|
||||
}
|
||||
log('reupload.ok', { kind, assetId: asset.id });
|
||||
return cdnUrl;
|
||||
}
|
||||
|
||||
async function recoverOrphans() {
|
||||
// Re-queue anything left in 'submitting' from a crashed previous run.
|
||||
const submitting = await prisma.job.updateMany({
|
||||
where: { status: 'submitting' },
|
||||
data: { status: 'queued', muapiRequestId: null },
|
||||
});
|
||||
if (submitting.count > 0) log('recover.submitting', { count: submitting.count });
|
||||
|
||||
// 'polling' jobs can keep going — we have their muapiRequestId.
|
||||
const stillPolling = await prisma.job.count({ where: { status: 'polling' } });
|
||||
if (stillPolling > 0) log('recover.polling', { count: stillPolling });
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function log(event, fields = {}) {
|
||||
const ts = new Date().toISOString();
|
||||
process.stdout.write(`[${ts}] ${event} ${JSON.stringify(fields)}\n`);
|
||||
}
|
||||
3
worker/package.json
Normal file
3
worker/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue