mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-08-30 17:33:11 +08:00
- 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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
Reference in New Issue
Block a user