Open-Generative-AI/components/StandaloneShell.js
Anuragp22 24e4bb40e5 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.
2026-04-23 09:41:55 +05:30

345 lines
13 KiB
JavaScript

'use client';
import { useState, useEffect, useCallback } from 'react';
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' },
{ id: 'video', label: 'Video Studio' },
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'agents', label: 'Agents' },
];
const STORAGE_KEY = 'muapi_key';
export default function StandaloneShell() {
const params = useParams();
const router = useRouter();
const slug = params?.slug || [];
const idFromParams = params?.id;
const tabFromParams = params?.tab;
// Helper to extract workflow details precisely from either route structure
const getWorkflowInfo = useCallback(() => {
if (idFromParams) {
return { id: idFromParams, tab: tabFromParams || null };
}
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
if (wfIndex === -1) return { id: null, tab: null };
return {
id: slug[wfIndex + 1] || null,
tab: slug[wfIndex + 2] || null
};
}, [slug, idFromParams, tabFromParams]);
const { id: urlWorkflowId } = getWorkflowInfo();
// Initialize activeTab from URL slug/params or default to 'image'
const getInitialTab = () => {
if (idFromParams || slug.includes('workflow')) return 'workflows';
if (slug.includes('agents')) return 'agents';
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) return firstSegment;
return 'image';
};
const [apiKey, setApiKey] = useState(null);
const [activeTab, setActiveTab] = useState(getInitialTab());
const [balance, setBalance] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
const [hasMounted, setHasMounted] = useState(false);
// Drag and Drop State
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState(null);
// Sync tab with URL if user navigates manually or via browser back/forward
useEffect(() => {
const info = getWorkflowInfo();
if (info.id) {
setActiveTab('workflows');
} else if (slug.includes('agents')) {
setActiveTab('agents');
} else {
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) {
setActiveTab(firstSegment);
}
}
}, [slug, getWorkflowInfo]);
const handleTabChange = (tabId) => {
setActiveTab(tabId);
router.push(`/studio/${tabId}`);
};
// Auto-hide header when inside a specific workflow view
useEffect(() => {
const isEditingWorkflow = (activeTab === 'workflows' || !!idFromParams) && urlWorkflowId;
if (isEditingWorkflow) {
setIsHeaderVisible(false);
} else {
setIsHeaderVisible(true);
}
}, [activeTab, urlWorkflowId, idFromParams]);
// Global builder CSS cleanup when switching away from Workflows tab
useEffect(() => {
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
if (fromBuilder && activeTab !== 'workflows') {
sessionStorage.removeItem("fromWorkflowBuilder");
window.location.reload();
}
}, [activeTab]);
const fetchBalance = useCallback(async (key) => {
try {
const data = await getUserBalance(key);
setBalance(data.balance);
} catch (err) {
console.error('Balance fetch failed:', err);
}
}, []);
useEffect(() => {
setHasMounted(true);
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
setApiKey(stored);
fetchBalance(stored);
// Sync cookie immediately on mount to establish identity for background requests
document.cookie = `muapi_key=${stored}; path=/; max-age=31536000; SameSite=Lax`;
}
}, [fetchBalance]);
const handleKeySave = useCallback((key) => {
localStorage.setItem(STORAGE_KEY, key);
setApiKey(key);
fetchBalance(key);
document.cookie = `muapi_key=${key}; path=/; max-age=31536000; SameSite=Lax`;
}, [fetchBalance]);
const handleKeyChange = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setApiKey(null);
setBalance(null);
document.cookie = "muapi_key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
}, []);
// Inject API key into all outgoing Axios requests (prop-based approach)
// We use an interceptor to be selective and NOT send the key to external domains like S3
useEffect(() => {
// Safety: Clear any global defaults that might have been set previously
delete axios.defaults.headers.common['x-api-key'];
if (!apiKey) return;
const interceptorId = axios.interceptors.request.use((config) => {
// Check if URL is local/proxied
const isRelative = config.url.startsWith('/') || !config.url.startsWith('http');
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers['x-api-key'] = apiKey;
}
return config;
});
return () => {
axios.interceptors.request.eject(interceptorId);
};
}, [apiKey]);
// Poll for balance every 30 seconds if key is present
useEffect(() => {
if (!apiKey) return;
const interval = setInterval(() => fetchBalance(apiKey), 30000);
return () => clearInterval(interval);
}, [apiKey, fetchBalance]);
// Drag and Drop Handlers
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
// Only set to false if we're leaving the container itself, not moving between children
if (e.currentTarget.contains(e.relatedTarget)) return;
setIsDragging(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
setDroppedFiles(files);
}
}, []);
const handleFilesHandled = useCallback(() => {
setDroppedFiles(null);
}, []);
if (!hasMounted) return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
</div>
);
if (!apiKey) {
return <ApiKeyModal onSave={handleKeySave} />;
}
return (
<div
className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white relative"
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag Overlay */}
{isDragging && (
<div className="fixed inset-0 z-[100] bg-[#d9ff00]/10 backdrop-blur-md border-4 border-dashed border-[#d9ff00]/50 flex items-center justify-center pointer-events-none transition-all duration-300">
<div className="bg-[#0a0a0a] p-8 rounded-3xl border border-white/10 shadow-2xl flex flex-col items-center gap-4 scale-110 animate-pulse">
<div className="w-20 h-20 bg-[#d9ff00] rounded-2xl flex items-center justify-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
</div>
<div className="flex flex-col items-center">
<span className="text-xl font-bold text-white">Drop your media here</span>
<span className="text-sm text-white/40">Images, videos, or audio files</span>
</div>
</div>
</div>
)}
{/* 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 + 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 */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
activeTab === tab.id
? 'text-[#d9ff00]'
: 'text-white/50 hover:text-white'
}`}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
)}
</button>
))}
</nav>
{/* Right: Actions */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<div className="flex flex-col">
<span className="text-xs font-bold text-white/90">
${balance !== null ? `${balance}` : '---'}
</span>
</div>
</div>
<div
onClick={() => setShowSettings(true)}
className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#d9ff00] to-yellow-200 border border-white/20 cursor-pointer hover:scale-105 transition-transform"
/>
</div>
</header>
)}
{/* Studio Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
{activeTab === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
{activeTab === 'agents' && <AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
</div>
{/* Settings Modal */}
{showSettings && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-8 w-full max-w-sm shadow-2xl">
<h2 className="text-white font-bold text-lg mb-2">Settings</h2>
<p className="text-white/40 text-[13px] mb-8">
Manage your AI studio preferences and authentication.
</p>
<div className="space-y-4 mb-8">
<div className="bg-white/5 border border-white/[0.03] rounded-md p-4">
<label className="block text-xs font-bold text-white/30 mb-2">
Active API Key
</label>
<div className="text-[13px] font-mono text-white/80">
{apiKey.slice(0, 8)}
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleKeyChange}
className="flex-1 h-10 rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 text-xs font-semibold transition-all"
>
Change Key
</button>
<button
onClick={() => setShowSettings(false)}
className="flex-1 h-10 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold transition-all border border-white/5"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}