feat: apply PRs #237, #242, #243, #234 from Open-Generative-AI

- feat(studio): add Body Swap (Recast) tab — RecastStudio component, recastModels
  (Kling 3.0 Pro Motion Control + Runway Act Two), processRecast() in muapi.js (#237)
- fix(local-ai): surface OOM/SIGKILL hint when sd-cli exits with null code (#242)
- feat(i18n): normalize zh→zh-CN locale, auto-detect browser language on first
  visit, add zh-CN translations for MCP hero + web shell strings (#243)
- fix(security): validate upload proxy target URL to S3 hostnames only,
  blocking SSRF to localhost/private IPs (#234)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Anil Matcha
2026-06-28 06:30:59 +05:30
parent e81f0e3edb
commit 612b12ddd3
12 changed files with 1097 additions and 25 deletions
+12 -3
View File
@@ -1,16 +1,25 @@
import { NextResponse } from 'next/server';
import { validateUploadProxyTarget } from '../../../src/lib/uploadProxyTarget';
export async function POST(request) {
try {
const formData = await request.formData();
// Extract the original S3 target URL we injected earlier
const targetUrl = formData.get('x-proxy-target-url');
if (!targetUrl) {
return NextResponse.json({ error: 'Missing proxy target URL' }, { status: 400 });
}
const validatedTarget = validateUploadProxyTarget(targetUrl);
if (!validatedTarget.ok) {
return NextResponse.json(
{ error: 'Invalid upload target', reason: validatedTarget.reason },
{ status: 400 }
);
}
// Reconstruct the FormData for S3 (excluding our internal proxy marker)
const s3FormData = new FormData();
@@ -25,7 +34,7 @@ export async function POST(request) {
// Perform the server-to-server POST to S3
// This bypasses browser CORS/Preflight security entirely
const s3Response = await fetch(targetUrl, {
const s3Response = await fetch(validatedTarget.url, {
method: 'POST',
body: s3FormData,
});
+12 -3
View File
@@ -1,16 +1,25 @@
import { NextResponse } from 'next/server';
import { validateUploadProxyTarget } from '../../../../src/lib/uploadProxyTarget';
export async function POST(request) {
try {
const formData = await request.formData();
// Extract the original S3 target URL
const targetUrl = formData.get('x-proxy-target-url');
if (!targetUrl) {
return NextResponse.json({ error: 'Missing proxy target URL' }, { status: 400 });
}
const validatedTarget = validateUploadProxyTarget(targetUrl);
if (!validatedTarget.ok) {
return NextResponse.json(
{ error: 'Invalid upload target', reason: validatedTarget.reason },
{ status: 400 }
);
}
const s3FormData = new FormData();
for (const [key, value] of formData.entries()) {
if (key !== 'x-proxy-target-url') {
@@ -18,7 +27,7 @@ export async function POST(request) {
}
}
const s3Response = await fetch(targetUrl, {
const s3Response = await fetch(validatedTarget.url, {
method: 'POST',
body: s3FormData,
});
+3 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ImageStudio, VideoStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, getUserBalance } from 'studio';
import { ImageStudio, VideoStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, RecastStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, getUserBalance } from 'studio';
const DesignAgentStudio = dynamic(() => import('studio').then(mod => mod.DesignAgentStudio), {
ssr: false,
@@ -19,6 +19,7 @@ const TABS = [
{ id: 'clipping', label: 'AI Clipping' },
{ id: 'vibe-motion', label: 'Vibe Motion' },
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'body-swap', label: 'Body Swap' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'marketing', label: 'Marketing Studio' },
{ id: 'workflows', label: 'Workflows' },
@@ -362,6 +363,7 @@ export default function StandaloneShell() {
{activeTab === 'clipping' && <ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'vibe-motion' && <VibeMotionStudio apiKey={apiKey} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'body-swap' && <RecastStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'audio' && <AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'marketing' && <MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
+5 -1
View File
@@ -535,7 +535,11 @@ async function generate(params, mainWindow) {
console.error('[sd-cli] full output:\n' + allOutput);
if (code !== 0) {
const tail = outputLines.filter(l => l.trim()).slice(-20).join('\n');
reject(new Error(`sd-cli exited (code ${code}):\n${tail}`));
const killed = code === null;
const hint = killed
? 'sd-cli was terminated before finishing (often OOM on Z-Image/SDXL — try a smaller SD 1.5 model or close other apps). '
: '';
reject(new Error(`${hint}sd-cli exited (code ${code ?? 'signal'}):\n${tail}`));
return;
}
if (!fs.existsSync(outPath)) {
@@ -0,0 +1,807 @@
++ b/packages/studio/src/components/RecastStudio.jsx
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { processRecast, uploadFile } from "../muapi.js";
import {
recastModels,
getRecastModelById,
getAspectRatiosForRecastModel,
} from "../models.js";
// ---------------------------------------------------------------------------
// Upload button states
// ---------------------------------------------------------------------------
const UPLOAD_STATE = {
IDLE: "idle",
UPLOADING: "uploading",
READY: "ready",
};
function MediaPickerButton({
accept,
label,
icon,
onUpload,
onClear,
uploadState,
progress,
fileName,
previewUrl,
isVideo,
}) {
const inputRef = useRef(null);
const handleClick = (e) => {
e.stopPropagation();
if (uploadState === UPLOAD_STATE.READY) {
onClear();
return;
}
inputRef.current?.click();
};
const handleChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
await onUpload(file);
};
const borderClass =
uploadState === UPLOAD_STATE.READY
? "border-primary/60 bg-primary/5"
: "border-white/[0.03] bg-white/[0.03] hover:bg-white/[0.06] hover:border-primary/40";
return (
<button
type="button"
title={
uploadState === UPLOAD_STATE.READY
? `${fileName} — click to clear`
: `Upload ${label.toLowerCase()} file`
}
onClick={handleClick}
className={`flex-shrink-0 w-10 h-10 rounded-full border transition-all flex items-center justify-center relative overflow-hidden group ${borderClass}`}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleChange}
/>
{/* Idle state */}
{uploadState === UPLOAD_STATE.IDLE && (
<div className="flex flex-col items-center justify-center gap-1 w-full h-full">
{icon}
</div>
)}
{/* Uploading indicator */}
{uploadState === UPLOAD_STATE.UPLOADING && (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * progress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{progress}%
</span>
</div>
)}
{/* Ready state */}
{uploadState === UPLOAD_STATE.READY && (
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-all">
{previewUrl ? (
isVideo ? (
<video
src={previewUrl}
className="w-full h-full object-cover"
muted
/>
) : (
<img
src={previewUrl}
alt=""
className="w-full h-full object-cover"
/>
)
) : (
icon
)}
</div>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Inline dropdown
// ---------------------------------------------------------------------------
function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
const dropRef = useRef(null);
const [style, setStyle] = useState({});
useEffect(() => {
if (!isOpen || !anchorRef?.current || !dropRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const ddHeight = dropRef.current.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
let top, bottom, maxHeight;
if (spaceBelow >= ddHeight || spaceBelow >= spaceAbove) {
top = rect.bottom + 8;
bottom = "auto";
maxHeight = Math.max(150, spaceBelow - 8);
} else {
top = "auto";
bottom = window.innerHeight - rect.top + 8;
maxHeight = Math.max(150, spaceAbove - 8);
}
const left = Math.min(rect.left, window.innerWidth - 220);
setStyle({ top, bottom, left, maxHeight });
}, [isOpen, anchorRef]);
useEffect(() => {
if (!isOpen) return;
const handler = (e) => {
if (
!dropRef.current?.contains(e.target) &&
!anchorRef?.current?.contains(e.target)
) {
onClose();
}
};
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [isOpen, onClose, anchorRef]);
if (!isOpen) return null;
return (
<div
ref={dropRef}
style={{
position: "fixed",
zIndex: 100,
overflowY: "auto",
...style,
}}
className="bg-[#111] border border-white/10 rounded-lg shadow-3xl p-2 custom-scrollbar w-[calc(100vw-3rem)] max-w-xs"
>
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onSelect(item);
onClose();
}}
className={`w-full text-left px-4 py-2 rounded text-sm transition-all hover:bg-white/10 ${
item.id === selectedId
? "text-primary font-bold bg-primary/5"
: "text-white font-medium"
}`}
>
<div>{item.name}</div>
{item.description && (
<div className="text-xs text-muted mt-0.5">
{item.description.slice(0, 60)}...
</div>
)}
</button>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// SVG icons
// ---------------------------------------------------------------------------
const VideoIcon = ({
className = "text-white/40 group-hover:text-primary transition-colors",
}) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={className}
>
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
);
const ImageIcon = ({
className = "text-white/40 group-hover:text-primary transition-colors",
}) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={className}
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function RecastStudio({
apiKey,
onGenerationComplete,
historyItems,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_recast_studio_persistent";
// ── Model state ───────────────────────────────────────────────────────────
const firstModel = recastModels[0];
const [selectedModelId, setSelectedModelId] = useState(firstModel?.id ?? "");
const [selectedAspectRatio, setSelectedAspectRatio] = useState(
firstModel?.inputs?.aspect_ratio?.default ?? "16:9",
);
// ── Upload state ──────────────────────────────────────────────────────────
const [videoState, setVideoState] = useState(UPLOAD_STATE.IDLE);
const [videoName, setVideoName] = useState("");
const [videoUrl, setVideoUrl] = useState(null);
const [videoProgress, setVideoProgress] = useState(0);
const [imageState, setImageState] = useState(UPLOAD_STATE.IDLE);
const [imageName, setImageName] = useState("");
const [imageUrl, setImageUrl] = useState(null);
const [imageProgress, setImageProgress] = useState(0);
// ── Prompt ────────────────────────────────────────────────────────────────
const [prompt, setPrompt] = useState("");
// ── Generation / UI state ─────────────────────────────────────────────────
const [isGenerating, setIsGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
// ── History ───────────────────────────────────────────────────────────────
const [internalHistory, setInternalHistory] = useState([]);
const history = historyItems ?? internalHistory;
// ── Dropdown state ────────────────────────────────────────────────────────
const [openDropdown, setOpenDropdown] = useState(null); // 'model' | 'aspect' | null
const modelBtnRef = useRef(null);
const aspectBtnRef = useRef(null);
const hasRestored = useRef(false);
// ── Persistence: Load ──────────────────────────────────────────────────────
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.selectedModelId) setSelectedModelId(data.selectedModelId);
if (data.selectedAspectRatio) setSelectedAspectRatio(data.selectedAspectRatio);
if (data.videoUrl) {
setVideoUrl(data.videoUrl);
setVideoState(UPLOAD_STATE.READY);
}
if (data.imageUrl) {
setImageUrl(data.imageUrl);
setImageState(UPLOAD_STATE.READY);
}
if (data.videoName) setVideoName(data.videoName);
if (data.imageName) setImageName(data.imageName);
if (data.prompt) setPrompt(data.prompt);
if (data.internalHistory) setInternalHistory(data.internalHistory);
}
} catch (err) {
console.warn("Failed to load RecastStudio persistence:", err);
} finally {
hasRestored.current = true;
}
}, []);
// ── Persistence: Save ──────────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
try {
localStorage.setItem(
PERSIST_KEY,
JSON.stringify({
selectedModelId,
selectedAspectRatio,
videoUrl,
videoName,
imageUrl,
imageName,
prompt,
internalHistory,
}),
);
} catch (err) {
console.warn("Failed to save RecastStudio persistence:", err);
}
}, 500);
return () => clearTimeout(timer);
}, [
selectedModelId,
selectedAspectRatio,
videoUrl,
videoName,
imageUrl,
imageName,
prompt,
internalHistory,
]);
// ── Derived model info ──────────────────────────────────────────────────────
const selectedModel = getRecastModelById(selectedModelId);
const aspectOptions = getAspectRatiosForRecastModel(selectedModelId);
const showAspect = aspectOptions.length > 0;
const showPrompt = !!selectedModel?.hasPrompt;
// ── Upload handlers ─────────────────────────────────────────────────────────
const handleVideoPick = useCallback(
async (file) => {
if (file.size > 50 * 1024 * 1024) {
alert("Video exceeds 50MB limit.");
return;
}
setVideoState(UPLOAD_STATE.UPLOADING);
setVideoProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => setVideoProgress(pct));
setVideoUrl(url);
setVideoName(file.name);
setVideoState(UPLOAD_STATE.READY);
} catch (err) {
setVideoState(UPLOAD_STATE.IDLE);
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoProgress(0);
}
},
[apiKey],
);
const handleImageUpload = useCallback(
async (file) => {
if (file.size > 10 * 1024 * 1024) {
alert("Image exceeds 10MB limit.");
return;
}
setImageState(UPLOAD_STATE.UPLOADING);
setImageProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => setImageProgress(pct));
setImageUrl(url);
setImageName(file.name);
setImageState(UPLOAD_STATE.READY);
} catch (err) {
setImageState(UPLOAD_STATE.IDLE);
alert(`Image upload failed: ${err.message}`);
} finally {
setImageProgress(0);
}
},
[apiKey],
);
// ── Handle Dropped Files ────────────────────────────────────────────────────
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/"));
const videoFiles = droppedFiles.filter((f) => f.type.startsWith("video/"));
if (videoFiles.length > 0) handleVideoPick(videoFiles[0]);
if (imageFiles.length > 0) handleImageUpload(imageFiles[0]);
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, handleVideoPick, handleImageUpload]);
// ── Model selection ─────────────────────────────────────────────────────────
const handleModelSelect = (model) => {
setSelectedModelId(model.id);
const ratios = getAspectRatiosForRecastModel(model.id);
if (ratios.length > 0) {
setSelectedAspectRatio(model.inputs?.aspect_ratio?.default ?? ratios[0]);
}
};
// ── History helpers ─────────────────────────────────────────────────────────
const addToInternalHistory = useCallback((entry) => {
setInternalHistory((prev) => [entry, ...prev].slice(0, 30));
}, []);
const downloadFile = async (url, filename) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(url, "_blank");
}
};
// ── Generation ──────────────────────────────────────────────────────────────
const handleGenerate = async () => {
if (!videoUrl) {
alert("Please upload a source video first.");
return;
}
if (!imageUrl) {
alert("Please upload a character image first.");
return;
}
setIsGenerating(true);
setGenerateError(null);
try {
const params = {
model: selectedModelId,
video_url: videoUrl,
image_url: imageUrl,
};
if (showAspect) params.aspect_ratio = selectedAspectRatio;
if (prompt && selectedModel?.hasPrompt) params.prompt = prompt;
const res = await processRecast(apiKey, params);
if (!res?.url) throw new Error("No video URL returned by API");
const genId = res.id || Date.now().toString();
const entry = {
id: genId,
url: res.url,
prompt,
model: selectedModel?.name || selectedModelId,
timestamp: new Date().toISOString(),
};
if (!historyItems) addToInternalHistory(entry);
if (onGenerationComplete) {
onGenerationComplete({
url: res.url,
model: selectedModelId,
prompt,
type: "recast",
});
}
} catch (e) {
console.error("[RecastStudio]", e);
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
setTimeout(() => setGenerateError(null), 4000);
} finally {
setIsGenerating(false);
}
};
// ── Dropdown item lists ─────────────────────────────────────────────────────
const aspectDropdownItems = aspectOptions.map((r) => ({ id: r, name: r }));
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden">
{/* ── CENTRAL GALLERY AREA ── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{history.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
>
<video
src={entry.url}
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
controls={false}
loop
muted
playsInline
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Download"
onClick={(e) => {
e.stopPropagation();
downloadFile(entry.url, `bodyswap-${entry.id || idx}.mp4`);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
</div>
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<div className="flex items-center justify-between flex-wrap gap-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 whitespace-nowrap">
{entry.model?.name || entry.model || "Body Swap"}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
<div className="mb-12 relative group">
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary opacity-80">
<path d="M16 3h5v5" />
<path d="M8 21H3v-5" />
<path d="M21 3l-7 7" />
<path d="M3 21l7-7" />
<circle cx="12" cy="12" r="2.2" />
</svg>
</div>
<div className="absolute top-4 right-4 text-[10px] text-primary/40 animate-pulse">🎭</div>
</div>
</div>
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
<span className="text-white/40 font-medium">START CREATING WITH</span><br />
<span className="text-white">BODY SWAP</span>
</h1>
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed">
Swap the character in any video drop in a clip and a character image
</p>
</div>
)}
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-[#0a0a0a]/80 backdrop-blur-3xl rounded-md border border-white/10 p-4 flex flex-col gap-2 shadow-2xl">
{/* Uploads row */}
<div className="flex items-center gap-2 px-1">
<div className="flex items-center gap-2">
{/* Source video */}
<MediaPickerButton
accept="video/*"
label="Video"
icon={<VideoIcon />}
onUpload={handleVideoPick}
onClear={() => {
setVideoUrl(null);
setVideoState(UPLOAD_STATE.IDLE);
setVideoName("");
}}
uploadState={videoState}
progress={videoProgress}
fileName={videoName}
previewUrl={videoUrl}
isVideo={true}
/>
{/* Character image */}
<MediaPickerButton
accept="image/*"
label="Character image"
icon={<ImageIcon />}
onUpload={handleImageUpload}
onClear={() => {
setImageUrl(null);
setImageState(UPLOAD_STATE.IDLE);
setImageName("");
}}
uploadState={imageState}
progress={imageProgress}
fileName={imageName}
previewUrl={imageUrl}
isVideo={false}
/>
</div>
{/* Hint / prompt */}
{showPrompt ? (
<div className="flex-1 flex flex-col">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Optional — describe the motion or scene..."
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
rows={1}
/>
</div>
) : (
<div className="flex-1 flex items-center pl-2">
<span className="text-xs text-white/30 font-medium">
Your Video + Character Image swapped video
</span>
</div>
)}
</div>
{/* Bottom controls row */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-2 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 px-1">
{/* Model selector */}
<div className="relative">
<button
ref={modelBtnRef}
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === "model" ? null : "model");
}}
className="flex items-center gap-2 px-2 py-1.5 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<div className="w-3.5 h-3.5 bg-[#22d3ee] rounded-sm flex items-center justify-center">
<span className="text-[9px] font-black text-black">R</span>
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedModel?.name ?? "Select model"}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<Dropdown
isOpen={openDropdown === "model"}
items={recastModels}
selectedId={selectedModelId}
onSelect={handleModelSelect}
onClose={() => setOpenDropdown(null)}
anchorRef={modelBtnRef}
/>
</div>
{/* Aspect ratio selector */}
{showAspect && (
<div className="relative">
<button
ref={aspectBtnRef}
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === "aspect" ? null : "aspect");
}}
className="flex items-center gap-2 px-2 py-1.5 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedAspectRatio}
</span>
</button>
<Dropdown
isOpen={openDropdown === "aspect"}
items={aspectDropdownItems}
selectedId={selectedAspectRatio}
onSelect={(item) => setSelectedAspectRatio(item.id)}
onClose={() => setOpenDropdown(null)}
anchorRef={aspectBtnRef}
/>
</div>
)}
</div>
{/* Generate button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-4 py-2 rounded-md font-medium text-sm hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span>{" "}
Swapping...
</>
) : generateError ? (
`Error: ${generateError}`
) : (
<span>Swap Body</span>
)}
</button>
</div>
</div>
</div>
{/* ── FULLSCREEN MEDIA MODAL ── */}
{fullscreenUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in"
onClick={() => setFullscreenUrl(null)}
>
<button
type="button"
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors border border-white/10"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(null);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<video
src={fullscreenUrl}
controls
autoPlay
loop
className="max-w-[95vw] max-h-[95vh] rounded-2xl shadow-2xl object-contain animate-scale-up"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
+1
View File
@@ -5,6 +5,7 @@ export { default as VideoStudio } from './components/VideoStudio';
export { default as ClippingStudio } from './components/ClippingStudio';
export { default as VibeMotionStudio } from './components/VibeMotionStudio';
export { default as LipSyncStudio } from './components/LipSyncStudio';
export { default as RecastStudio } from './components/RecastStudio';
export { default as CinemaStudio } from './components/CinemaStudio';
export { default as AudioStudio } from './components/AudioStudio';
export { default as MarketingStudio } from './components/MarketingStudio';
+42
View File
@@ -8240,6 +8240,48 @@ export const videoLipSyncModels = lipsyncModels.filter(m => m.category === 'vide
export const getV2VModelById = (id) => v2vModels.find(m => m.id === id);
// ─── Recast / Body Swap models ───────────────────────────────────────────────
// Source video (the performance / motion) + character image (the new identity)
// → a video of the new character performing the source video's motion.
export const recastModels = [
{
"id": "kling-v3.0-pro-recast",
"name": "Kling 3.0 Pro Motion Control",
"endpoint": "kling-v3.0-pro-motion-control",
"family": "kling",
"videoField": "video_url",
"imageField": "image_url",
"hasPrompt": true,
"description": "Transfer the motion from your video onto a character image with maximum fidelity."
},
{
"id": "runway-act-two-recast",
"name": "Runway Act Two",
"endpoint": "runway-act-two-i2v",
"family": "runway",
"videoField": "video_url",
"imageField": "image_url",
"hasPrompt": false,
"inputs": {
"aspect_ratio": {
"type": "string",
"title": "Aspect Ratio",
"name": "aspect_ratio",
"enum": ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
"default": "16:9"
}
},
"description": "Recast any character — drive a character image with the motion and performance from your video."
}
];
export const getRecastModelById = (id) => recastModels.find(m => m.id === id);
export const getAspectRatiosForRecastModel = (id) => {
const model = recastModels.find(m => m.id === id);
return model?.inputs?.aspect_ratio?.enum || [];
};
// ── Audio Models ──────────────────────────────────────────────────────────
export const audioModels = [
+18 -1
View File
@@ -1,4 +1,4 @@
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById, getAudioModelById } from './models.js';
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getRecastModelById, getLipSyncModelById, getAudioModelById } from './models.js';
// In an http(s) browser we route through the host app's proxy (Next.js routes
// under /api/* re-issue the call server-side) so api.muapi.ai CORS is bypassed.
@@ -174,6 +174,23 @@ export async function processV2V(apiKey, params) {
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function processRecast(apiKey, params) {
const modelInfo = getRecastModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const videoField = modelInfo?.videoField || 'video_url';
const payload = { [videoField]: params.video_url };
if (modelInfo?.imageField && params.image_url) {
payload[modelInfo.imageField] = params.image_url;
}
if (modelInfo?.hasPrompt && params.prompt) {
payload.prompt = params.prompt;
}
if (params.aspect_ratio) {
payload.aspect_ratio = params.aspect_ratio;
}
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function processLipSync(apiKey, params) {
const modelInfo = getLipSyncModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
+4 -4
View File
@@ -66,7 +66,7 @@ export function Header(navigate) {
const settingsBtn = document.createElement('button');
settingsBtn.className = 'flex items-center gap-2 px-3 py-1.5 rounded-md border border-white/10 bg-white/5 text-[13px] font-bold text-white/80 hover:text-white hover:bg-white/10 hover:border-white/20 transition-colors';
settingsBtn.title = 'Settings — API key, local models, preferences';
settingsBtn.title = t('web.settingsTitle');
settingsBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
@@ -82,9 +82,9 @@ export function Header(navigate) {
const langBtn = document.createElement('button');
const currentLang = getLang();
langBtn.className = 'flex items-center px-3 py-1.5 rounded-md border border-white/10 bg-white/5 text-[13px] font-bold text-white/80 hover:text-white hover:bg-white/10 hover:border-white/20 transition-colors';
langBtn.title = currentLang === 'zh' ? 'Switch to English' : '切换为中文';
langBtn.textContent = currentLang === 'zh' ? 'EN' : '中文';
langBtn.onclick = () => setLang(currentLang === 'zh' ? 'en' : 'zh');
langBtn.title = currentLang === 'zh-CN' ? t('web.switchToEn') : t('web.switchToZh');
langBtn.textContent = currentLang === 'zh-CN' ? 'EN' : '中文';
langBtn.onclick = () => setLang(currentLang === 'zh-CN' ? 'en' : 'zh-CN');
rightPart.appendChild(langBtn);
rightPart.appendChild(settingsBtn);
+6 -6
View File
@@ -1,3 +1,5 @@
import { t } from '../lib/i18n.js';
export function McpCliStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full overflow-y-auto bg-app-bg text-white';
@@ -11,13 +13,11 @@ export function McpCliStudio() {
hero.className = 'flex flex-col items-center text-center gap-4';
hero.innerHTML = `
<div class="px-3 py-1 rounded-full border border-white/10 bg-white/5 text-[11px] font-bold uppercase tracking-widest text-secondary">
For developers & AI agents
${t('mcp.tagline')}
</div>
<h1 class="text-4xl md:text-5xl font-bold tracking-tight">MCP &amp; CLI</h1>
<h1 class="text-4xl md:text-5xl font-bold tracking-tight">${t('mcp.title')}</h1>
<p class="text-secondary text-base md:text-lg max-w-2xl">
Use Open Generative AI from your terminal, your IDE, or any MCP-compatible
assistant. Generate cinematic images, videos, and audio across 100+ models —
without leaving your workflow.
${t('mcp.subtitle')}
</p>
`;
inner.appendChild(hero);
@@ -27,7 +27,7 @@ export function McpCliStudio() {
quick.className = 'glass-panel rounded-2xl p-6 md:p-8 flex flex-col gap-4';
quick.innerHTML = `
<div class="flex items-center gap-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Quick start</span>
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">${t('mcp.quickStart')}</span>
<div class="flex-1 h-px bg-white/5"></div>
</div>
<div class="grid md:grid-cols-3 gap-4">
+69 -6
View File
@@ -1,12 +1,51 @@
const LANG_KEY = 'og_lang';
export function getLang() {
return localStorage.getItem(LANG_KEY) || 'en';
/** Normalize legacy `zh` and browser locales to BCP-47 zh-CN. */
export function normalizeLang(raw) {
if (!raw) return 'en';
const lower = String(raw).toLowerCase();
if (lower === 'zh' || lower.startsWith('zh-') || lower.startsWith('zh_')) return 'zh-CN';
return lower === 'zh-cn' ? 'zh-CN' : 'en';
}
export function setLang(lang) {
/** Detect browser locale on first visit; migrates stored `zh` → `zh-CN`. */
export function initLocale() {
if (typeof localStorage === 'undefined') return 'en';
const stored = localStorage.getItem(LANG_KEY);
if (stored) {
const normalized = normalizeLang(stored);
if (normalized !== stored) localStorage.setItem(LANG_KEY, normalized);
return normalized;
}
const detected = typeof navigator !== 'undefined' ? navigator.language : 'en';
const lang = normalizeLang(detected);
localStorage.setItem(LANG_KEY, lang);
location.reload();
return lang;
}
export function getLang() {
if (typeof localStorage === 'undefined') return 'en';
const stored = localStorage.getItem(LANG_KEY);
if (!stored) return initLocale();
const normalized = normalizeLang(stored);
if (normalized !== stored) localStorage.setItem(LANG_KEY, normalized);
return normalized;
}
export function setLang(lang, { reload = true } = {}) {
const normalized = normalizeLang(lang);
localStorage.setItem(LANG_KEY, normalized);
if (reload && typeof location !== 'undefined') {
location.reload();
} else if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('og_lang_change', { detail: normalized }));
}
}
function dictFor(lang) {
const key = normalizeLang(lang);
if (key === 'zh-CN') return translations['zh-CN'] || translations.zh;
return translations.en;
}
const translations = {
@@ -199,6 +238,17 @@ const translations = {
'localModels.probing': 'Probing...',
'localModels.errorLoading': 'Error loading models: ',
'localModels.deleteConfirm': (name) => `Delete "${name}"? You'll need to re-download it to use it again.`,
// Web shell
'web.settingsTitle': 'Settings — API key, local models, preferences',
'web.switchToEn': 'Switch to English',
'web.switchToZh': '切换为中文',
// MCP & CLI page
'mcp.tagline': 'For developers & AI agents',
'mcp.title': 'MCP & CLI',
'mcp.subtitle': 'Use Open Generative AI from your terminal, your IDE, or any MCP-compatible assistant. Generate cinematic images, videos, and audio across 100+ models — without leaving your workflow.',
'mcp.quickStart': 'Quick start',
},
zh: {
// Navigation
@@ -389,19 +439,32 @@ const translations = {
'localModels.probing': '探测中...',
'localModels.errorLoading': '加载模型时出错:',
'localModels.deleteConfirm': (name) => `删除"${name}"?您需要重新下载才能再次使用。`,
// Web shell
'web.settingsTitle': '设置 — API 密钥、本地模型、偏好',
'web.switchToEn': 'Switch to English',
'web.switchToZh': '切换为中文',
// MCP & CLI page
'mcp.tagline': '面向开发者与 AI 智能体',
'mcp.title': 'MCP & CLI',
'mcp.subtitle': '在终端、IDE 或任何兼容 MCP 的助手中使用 Open Generative AI。跨 100+ 模型生成电影级图像、视频和音频 — 无需离开您的工作流。',
'mcp.quickStart': '快速开始',
},
};
translations['zh-CN'] = translations.zh;
export function t(key) {
const lang = getLang();
const dict = translations[lang] || translations.en;
const dict = dictFor(lang);
const val = dict[key] !== undefined ? dict[key] : (translations.en[key] !== undefined ? translations.en[key] : key);
return typeof val === 'function' ? val : val;
}
export function tf(key, ...args) {
const lang = getLang();
const dict = translations[lang] || translations.en;
const dict = dictFor(lang);
const val = dict[key] !== undefined ? dict[key] : (translations.en[key] !== undefined ? translations.en[key] : key);
return typeof val === 'function' ? val(...args) : val;
}
+118
View File
@@ -0,0 +1,118 @@
const DEFAULT_S3_REGION_PATTERN = /^[a-z0-9-]+$/;
function normalizeHostname(hostname) {
return hostname.toLowerCase().replace(/\.$/, '');
}
function parseAllowedHosts(env) {
return (env.UPLOAD_PROXY_ALLOWED_HOSTS || '')
.split(',')
.map((host) => normalizeHostname(host.trim()))
.filter(Boolean);
}
function parseIpV4(hostname) {
const parts = hostname.split('.');
if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const octets = parts.map((part) => Number(part));
if (octets.some((octet) => octet < 0 || octet > 255)) {
return null;
}
return octets;
}
function isIpLiteral(hostname) {
return Boolean(parseIpV4(hostname)) || hostname.includes(':');
}
function isBlockedIpV4(hostname) {
const octets = parseIpV4(hostname);
if (!octets) {
return false;
}
const [first, second] = octets;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
function isBlockedHost(hostname) {
const normalized = normalizeHostname(hostname).replace(/^\[|\]$/g, '');
return (
normalized === 'localhost' ||
normalized === '::1' ||
isIpLiteral(normalized) ||
isBlockedIpV4(normalized)
);
}
function isAllowedS3Host(hostname) {
// Reject empty labels (leading dot, trailing dot, or consecutive dots).
if (hostname.split('.').some((label) => label === '')) {
return false;
}
if (hostname === 's3.amazonaws.com') {
return true;
}
if (hostname.endsWith('.s3.amazonaws.com')) {
return hostname.length > '.s3.amazonaws.com'.length;
}
const labels = hostname.split('.');
if (labels.length === 4 && labels[0] === 's3' && labels[2] === 'amazonaws' && labels[3] === 'com') {
return DEFAULT_S3_REGION_PATTERN.test(labels[1]);
}
if (labels.length >= 5 && labels[labels.length - 2] === 'amazonaws' && labels[labels.length - 1] === 'com') {
const s3LabelIndex = labels.findIndex((label) => label === 's3');
return (
s3LabelIndex > 0 &&
labels.length - s3LabelIndex === 4 &&
DEFAULT_S3_REGION_PATTERN.test(labels[s3LabelIndex + 1])
);
}
return false;
}
export function validateUploadProxyTarget(rawTarget, { env = process.env } = {}) {
if (typeof rawTarget !== 'string' || rawTarget.trim() === '') {
return { ok: false, reason: 'missing_target' };
}
let url;
try {
url = new URL(rawTarget);
} catch {
return { ok: false, reason: 'invalid_url' };
}
if (url.protocol !== 'https:') {
return { ok: false, reason: 'unsafe_protocol' };
}
const hostname = normalizeHostname(url.hostname);
if (isBlockedHost(hostname)) {
return { ok: false, reason: 'host_not_allowed' };
}
const allowedHosts = parseAllowedHosts(env);
if (!isAllowedS3Host(hostname) && !allowedHosts.includes(hostname)) {
return { ok: false, reason: 'host_not_allowed' };
}
return { ok: true, url: url.toString() };
}