chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:02:44 +08:00
commit 33645b2d9d
144 changed files with 63699 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { t } from '../lib/i18n.js';
export function AgentStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg text-white gap-4';
const icon = document.createElement('div');
icon.innerHTML = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<path d="M12 2a4 4 0 0 1 4 4 4 4 0 0 1-4 4 4 4 0 0 1-4-4 4 4 0 0 1 4-4"/>
<path d="M8 14a6 6 0 0 0-6 6h20a6 6 0 0 0-6-6H8z"/>
<path d="M9 9h.01M15 9h.01"/>
</svg>`;
const title = document.createElement('p');
title.textContent = t('agents.title');
title.className = 'text-lg font-bold opacity-60';
const sub = document.createElement('p');
sub.textContent = t('agents.webOnly');
sub.className = 'text-sm opacity-40';
container.appendChild(icon);
container.appendChild(title);
container.appendChild(sub);
return container;
}
+84
View File
@@ -0,0 +1,84 @@
import { t } from '../lib/i18n.js';
export function AuthModal(onSuccess) {
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm px-6';
const modal = document.createElement('div');
modal.className = 'relative w-full max-w-md bg-panel-bg border border-white/10 rounded-3xl p-8 shadow-3xl animate-fade-in-up';
modal.innerHTML = `
<button id="auth-modal-close-btn" type="button" aria-label="Close" class="absolute top-4 right-4 w-8 h-8 rounded-md text-white/40 hover:text-white hover:bg-white/10 transition-colors flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<div class="flex flex-col items-center text-center mb-8">
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center border border-primary/20 shadow-glow mb-6">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" stroke-width="2">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3m-3-3l-2.25-2.25"/>
</svg>
</div>
<h2 class="text-2xl font-black text-white uppercase tracking-wider mb-2">${t('auth.title')}</h2>
<p class="text-secondary text-sm">${t('auth.subtitle')}</p>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-bold text-muted uppercase tracking-widest ml-1">${t('auth.keyLabel')}</label>
<input
type="password"
id="muapi-key-input"
placeholder="${t('auth.keyPlaceholder')}"
class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-4 text-white placeholder:text-muted focus:outline-none focus:border-primary/50 transition-colors shadow-inner"
>
<p class="text-[11px] text-muted ml-1">${t('auth.keyNote')}</p>
</div>
<div class="flex flex-col gap-3">
<button id="save-key-btn" class="w-full bg-primary text-black font-black py-4 rounded-2xl hover:shadow-glow hover:scale-[1.02] active:scale-[0.98] transition-all">
${t('auth.initBtn')}
</button>
<a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" class="text-center text-[11px] font-bold text-muted hover:text-white transition-colors py-2 uppercase tracking-tighter">
${t('auth.createKey')}
</a>
</div>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const input = modal.querySelector('#muapi-key-input');
const btn = modal.querySelector('#save-key-btn');
const closeBtn = modal.querySelector('#auth-modal-close-btn');
const close = () => {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) document.body.removeChild(overlay);
};
const onKeydown = (e) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKeydown);
closeBtn.onclick = close;
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
btn.onclick = () => {
const key = input.value.trim();
if (key) {
localStorage.setItem('muapi_key', key);
close();
if (onSuccess) onSuccess();
} else {
input.classList.add('border-red-500/50');
setTimeout(() => input.classList.remove('border-red-500/50'), 2000);
}
};
return overlay;
}
+251
View File
@@ -0,0 +1,251 @@
import { CAMERA_MAP, LENS_MAP, FOCAL_PERSPECTIVE, APERTURE_EFFECT } from '../lib/promptUtils.js';
const ASSET_URLS = {
// CAMERA
"Modular 8K Digital": "./assets/cinema/modular_8k_digital.webp",
"Full-Frame Cine Digital": "./assets/cinema/full_frame_cine_digital.webp",
"Grand Format 70mm Film": "./assets/cinema/grand_format_70mm_film.webp",
"Studio Digital S35": "./assets/cinema/studio_digital_s35.webp",
"Classic 16mm Film": "./assets/cinema/classic_16mm_film.webp",
"Premium Large Format Digital": "./assets/cinema/premium_large_format_digital.webp",
// LENS
"Creative Tilt Lens": "./assets/cinema/creative_tilt_lens.webp",
"Compact Anamorphic": "./assets/cinema/compact_anamorphic.webp",
"Extreme Macro": "./assets/cinema/extreme_macro.webp",
"70s Cinema Prime": "./assets/cinema/70s_cinema_prime.webp",
"Classic Anamorphic": "./assets/cinema/classic_anamorphic.webp",
"Premium Modern Prime": "./assets/cinema/premium_modern_prime.webp",
"Warm Cinema Prime": "./assets/cinema/warm_cinema_prime.webp",
"Swirl Bokeh Portrait": "./assets/cinema/swirl_bokeh_portrait.webp",
"Vintage Prime": "./assets/cinema/vintage_prime.webp",
"Halation Diffusion": "./assets/cinema/halation_diffusion.webp",
"Clinical Sharp Prime": "./assets/cinema/clinical_sharp_prime.webp",
// APERTURE
"f/1.4": "./assets/cinema/f_1_4.webp",
"f/4": "./assets/cinema/f_4.webp",
"f/11": "./assets/cinema/f_11.webp"
};
export function CameraControls(onChange) {
const container = document.createElement('div');
// Added padding-bottom to ensure scrollbar doesn't overlap content if visible
// Changed justify-center to justify-start md:justify-center to allow left-aligned scrolling on mobile
container.className = 'w-full flex justify-start md:justify-center gap-3 md:gap-6 py-4 md:py-8 overflow-x-auto no-scrollbar snap-x px-4 md:px-0';
let state = {
camera: Object.keys(CAMERA_MAP)[0],
lens: Object.keys(LENS_MAP)[0],
focal: 35,
aperture: "f/1.4"
};
const updateState = (key, value) => {
state[key] = value;
if (onChange) onChange(state);
};
const createColumn = (title, items, key, initialValue) => {
const colWrapper = document.createElement('div');
colWrapper.className = 'flex flex-col items-center relative w-[140px] md:w-[160px] shrink-0 snap-center group';
const viewport = document.createElement('div');
// Responsive height: h-[50vh] on mobile, h-[320px] on desktop
viewport.className = 'relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#1a1a1a]/80 rounded-[2rem] border border-white/5 shadow-2xl backdrop-blur-xl transition-transform duration-300 hover:scale-[1.02] hover:border-white/10';
const list = document.createElement('div');
list.className = 'h-full overflow-y-auto no-scrollbar snap-y snap-mandatory relative z-10';
// Spacer to allow first item to be centered
const topSpacer = document.createElement('div');
topSpacer.style.height = 'calc(50% - 50px)'; // Half viewport - half item height
list.appendChild(topSpacer);
const topMask = document.createElement('div');
topMask.className = 'absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#1a1a1a] via-[#1a1a1a]/80 to-transparent z-20 pointer-events-none rounded-t-[2rem]';
viewport.appendChild(topMask);
const bottomMask = document.createElement('div');
bottomMask.className = 'absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#1a1a1a] via-[#1a1a1a]/80 to-transparent z-20 pointer-events-none rounded-b-[2rem]';
viewport.appendChild(bottomMask);
const glow = document.createElement('div');
glow.className = 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4/5 h-[80px] bg-primary/5 blur-xl rounded-full pointer-events-none z-0';
viewport.appendChild(glow);
// DRAG TO SCROLL LOGIC
let isDown = false;
let startY;
let scrollTop;
list.addEventListener('mousedown', (e) => {
isDown = true;
list.classList.add('cursor-grabbing');
list.classList.remove('cursor-pointer', 'snap-y'); // Disable snap while dragging
startY = e.pageY - list.offsetTop;
scrollTop = list.scrollTop;
e.preventDefault(); // Prevent text selection
});
list.addEventListener('mouseleave', () => {
isDown = false;
list.classList.remove('cursor-grabbing');
list.classList.add('snap-y');
});
list.addEventListener('mouseup', () => {
isDown = false;
list.classList.remove('cursor-grabbing');
list.classList.add('snap-y');
});
list.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const y = e.pageY - list.offsetTop;
const walk = (y - startY) * 1.5; // Scroll speed multiplier
list.scrollTop = scrollTop - walk;
});
items.forEach(item => {
const itemEl = document.createElement('div');
itemEl.className = `
h-[100px] flex flex-col items-center justify-center gap-3
snap-center cursor-pointer transition-all duration-500 ease-out
text-white p-2 select-none opacity-30 scale-75 blur-[1px]
`;
const imageUrl = ASSET_URLS[item];
// Image Container
const imgContainer = document.createElement('div');
imgContainer.className = `w-14 h-14 rounded-xl border border-white/10 bg-white/5 flex items-center justify-center transition-all duration-500 shadow-inner group-hover/item:border-primary/30 overflow-hidden relative`;
if (imageUrl) {
const img = document.createElement('img');
img.src = imageUrl;
img.className = 'w-full h-full object-cover opacity-80';
imgContainer.appendChild(img);
} else if (key === 'focal') {
// For Focal Length (Numbers), use text/simple graphics
const focalText = document.createElement('span');
focalText.textContent = item;
focalText.className = 'text-lg font-bold text-white/50';
imgContainer.appendChild(focalText);
} else {
// Fallback for missing images
imgContainer.innerHTML = `<div class="w-3 h-3 bg-white/20 rounded-full"></div>`;
}
const text = document.createElement('span');
text.textContent = item;
text.className = 'text-[9px] md:text-[10px] font-bold uppercase text-center leading-tight max-w-full truncate px-1 tracking-wider';
itemEl.appendChild(imgContainer);
itemEl.appendChild(text);
itemEl.onclick = () => {
itemEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
itemEl.dataset.value = item;
list.appendChild(itemEl);
});
// Spacer to allow last item to be centered
const bottomSpacer = document.createElement('div');
bottomSpacer.style.height = 'calc(50% - 50px)';
list.appendChild(bottomSpacer);
viewport.appendChild(list);
const label = document.createElement('div');
label.className = 'mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center';
label.textContent = title;
colWrapper.appendChild(label);
colWrapper.appendChild(viewport);
// Scroll-based selection logic (Guarantees one active item)
const handleScroll = () => {
const centerY = list.scrollTop + (list.clientHeight / 2);
let closest = null;
let minDist = Infinity;
const children = Array.from(list.children).filter(c => c.dataset.value); // Ignore spacers
// 1. Find closest item first
children.forEach(child => {
const childCenter = child.offsetTop + (child.offsetHeight / 2);
const dist = Math.abs(centerY - childCenter);
if (dist < minDist) {
minDist = dist;
closest = child;
}
});
// 2. Apply styles based on closest match
children.forEach(child => {
const imgBox = child.querySelector('div');
const label = child.querySelector('span:last-child');
const isClosest = child === closest;
if (isClosest) {
// Active Item
child.classList.remove('opacity-30', 'scale-75', 'blur-[1px]');
child.classList.add('opacity-100', 'scale-100', 'blur-0', 'z-30');
imgBox.classList.add('border-primary/50', 'shadow-glow-sm', 'scale-110');
imgBox.classList.remove('border-white/10', 'bg-white/5');
if (key === 'focal') {
const fText = imgBox.querySelector('span');
if (fText) fText.classList.add('text-primary');
}
label.classList.add('text-primary', 'text-shadow-sm');
} else {
// Inactive Items
child.classList.add('opacity-30', 'scale-75', 'blur-[1px]');
child.classList.remove('opacity-100', 'scale-100', 'blur-0', 'z-30');
imgBox.classList.remove('border-primary/50', 'shadow-glow-sm', 'scale-110');
imgBox.classList.add('border-white/10', 'bg-white/5');
if (key === 'focal') {
const fText = imgBox.querySelector('span');
if (fText) fText.classList.remove('text-primary');
}
label.classList.remove('text-primary', 'text-shadow-sm');
}
});
if (closest && closest.dataset.value !== state[key]) {
updateState(key, closest.dataset.value);
}
};
list.addEventListener('scroll', handleScroll);
// Initial check
setTimeout(handleScroll, 150);
setTimeout(() => {
const initialItem = Array.from(list.children).find(c => c.dataset.value == initialValue);
if (initialItem) initialItem.scrollIntoView({ block: 'center' });
}, 100);
return colWrapper;
};
container.appendChild(createColumn('Camera', Object.keys(CAMERA_MAP), 'camera', state.camera));
container.appendChild(createColumn('Lens', Object.keys(LENS_MAP), 'lens', state.lens));
container.appendChild(createColumn('Focal Length', Object.keys(FOCAL_PERSPECTIVE).map(k => parseInt(k)), 'focal', state.focal));
container.appendChild(createColumn('Aperture', Object.keys(APERTURE_EFFECT), 'aperture', state.aperture));
return container;
}
+595
View File
@@ -0,0 +1,595 @@
import { muapi } from '../lib/muapi.js';
import { CameraControls } from './CameraControls.js';
import { buildNanoBananaPrompt, CAMERA_MAP, LENS_MAP, FOCAL_PERSPECTIVE, APERTURE_EFFECT } from '../lib/promptUtils.js';
import { AuthModal } from './AuthModal.js';
import { t } from '../lib/i18n.js';
export function CinemaStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-black relative overflow-hidden';
// --- State ---
const currentSettings = {
prompt: '',
aspect_ratio: '16:9',
camera: Object.keys(CAMERA_MAP)[0],
lens: Object.keys(LENS_MAP)[0],
focal: 35,
aperture: "f/1.4"
};
// Camera builder panel state
let showCameraBuilder = false;
// ==========================================
// 1. HERO SECTION (Empty State)
// ==========================================
const heroSection = document.createElement('div');
heroSection.className = 'flex flex-col items-center justify-center text-center px-4 animate-fade-in-up';
heroSection.innerHTML = `
<div class="mb-4 text-xs font-bold text-white/40 tracking-[0.2em] uppercase">${t('cinema.tagline')}</div>
<h1 class="text-4xl md:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-b from-white to-white/50 tracking-tight leading-tight mb-2">
${t('cinema.headline')}
</h1>
`;
container.appendChild(heroSection);
// ==========================================
// 2. CAMERA CONTROLS OVERLAY
// ==========================================
const overlayBackdrop = document.createElement('div');
overlayBackdrop.className = 'fixed inset-0 bg-black/80 backdrop-blur-md z-40 opacity-0 pointer-events-none transition-opacity duration-300 flex items-center justify-center';
const overlayContent = document.createElement('div');
// Reduced padding for mobile (p-4) and added max-height/overflow handling
overlayContent.className = 'w-full max-w-4xl bg-[#141414] border border-white/10 rounded-3xl p-4 md:p-8 shadow-2xl transform scale-95 transition-transform duration-300 flex flex-col max-h-[90vh]';
overlayBackdrop.appendChild(overlayContent);
// Header for Overlay
const overlayHeader = document.createElement('div');
overlayHeader.className = 'flex items-center justify-between mb-8';
overlayHeader.innerHTML = `
<div class="flex gap-4">
<button class="px-4 py-2 bg-white text-black text-xs font-bold rounded-full">All</button>
</div>
<button id="close-overlay-btn" class="text-white/50 hover:text-white transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
`;
overlayContent.appendChild(overlayHeader);
// Controls Component
const cameraControls = CameraControls((state) => {
currentSettings.camera = state.camera;
currentSettings.lens = state.lens;
currentSettings.focal = state.focal;
currentSettings.aperture = state.aperture;
updateSummaryCard();
});
overlayContent.appendChild(cameraControls);
document.body.appendChild(overlayBackdrop); // Append to body to sit above everything
// Overlay Logic
const openOverlay = () => {
overlayBackdrop.classList.remove('opacity-0', 'pointer-events-none');
overlayContent.classList.remove('scale-95');
overlayContent.classList.add('scale-100');
};
const closeOverlay = () => {
overlayBackdrop.classList.add('opacity-0', 'pointer-events-none');
overlayContent.classList.add('scale-95');
overlayContent.classList.remove('scale-100');
};
overlayContent.querySelector('#close-overlay-btn').onclick = closeOverlay;
overlayBackdrop.onclick = (e) => { if (e.target === overlayBackdrop) closeOverlay(); };
// ==========================================
// 3. FLOATING PROMPT BAR
// ==========================================
const promptBarWrapper = document.createElement('div');
promptBarWrapper.className = 'absolute bottom-8 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-4xl z-30';
const promptBar = document.createElement('div');
promptBar.className = 'bg-[#1a1a1a] border border-white/10 rounded-[2rem] p-4 flex justify-between shadow-3xl items-end relative';
// --- LEFT COLUMN (Input + Settings) ---
const leftColumn = document.createElement('div');
leftColumn.className = 'flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1 px-1';
// 1. Input Area
const inputRow = document.createElement('div');
inputRow.className = 'flex items-start gap-3 w-full';
// Textarea
const textarea = document.createElement('textarea');
textarea.placeholder = t('cinema.placeholder');
textarea.className = 'flex-1 bg-transparent border-none text-white text-lg font-medium placeholder:text-white/20 focus:outline-none resize-none h-[28px] leading-relaxed overflow-hidden';
textarea.style.height = 'auto'; // Auto-grow check
textarea.rows = 1;
textarea.oninput = function () {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
};
inputRow.appendChild(textarea);
leftColumn.appendChild(inputRow);
// 2. Settings Toolbar (Bottom Left)
// 2. Settings Toolbar (Bottom Left)
const settingsToolbar = document.createElement('div');
settingsToolbar.className = 'flex items-center gap-3'; // Removed pl-11 to align left
// Helper: Create Dropdown
const createDropdown = (items, selected, onSelect, trigger) => {
const existing = document.querySelectorAll('.custom-dropdown');
existing.forEach(el => el.remove());
const rect = trigger.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'custom-dropdown fixed bg-[#1a1a1a] border border-white/10 rounded-xl py-1 shadow-2xl z-50 flex flex-col min-w-[100px] animate-fade-in';
menu.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
menu.style.left = rect.left + 'px';
items.forEach(item => {
const btn = document.createElement('button');
btn.className = `px-3 py-2 text-xs font-bold text-left hover:bg-white/10 transition-colors ${item === selected ? 'text-primary' : 'text-white'}`;
btn.textContent = item;
btn.onclick = (e) => {
e.stopPropagation();
onSelect(item);
menu.remove();
};
menu.appendChild(btn);
});
const closeHandler = (e) => {
if (!menu.contains(e.target) && e.target !== trigger) {
menu.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
document.body.appendChild(menu);
};
// Aspect Ratio
const arBtn = document.createElement('button');
arBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5';
const updateArBtn = () => {
arBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="10" rx="2" ry="2"/></svg> ${currentSettings.aspect_ratio}`;
};
updateArBtn();
arBtn.onclick = () => {
createDropdown(['16:9', '21:9', '9:16', '1:1', '4:5'], currentSettings.aspect_ratio, (val) => {
currentSettings.aspect_ratio = val;
updateArBtn();
}, arBtn);
};
settingsToolbar.appendChild(arBtn);
// Resolution
const resBtn = document.createElement('button');
resBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5';
const updateResBtn = (val) => {
resBtn.dataset.value = val || '2K';
resBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg> ${resBtn.dataset.value}`;
};
updateResBtn('2K');
resBtn.onclick = () => {
createDropdown(['1K', '2K', '4K'], resBtn.dataset.value, (val) => { updateResBtn(val); }, resBtn);
};
settingsToolbar.appendChild(resBtn);
// Camera Builder Toggle Button
const cameraBuilderBtn = document.createElement('button');
cameraBuilderBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5';
cameraBuilderBtn.setAttribute('data-tooltip', t('cinema.builderTooltip'));
cameraBuilderBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/></svg> Builder`;
settingsToolbar.appendChild(cameraBuilderBtn);
leftColumn.appendChild(settingsToolbar);
promptBar.appendChild(leftColumn);
// --- RIGHT GROUP (Summary + Generate) ---
const rightGroup = document.createElement('div');
rightGroup.className = 'flex items-center gap-2 h-full self-end mb-1';
// Summary Card (Triggers Overlay)
const summaryCard = document.createElement('button');
// Removed 'hidden' class, added 'flex' and refined width constraints for mobile
summaryCard.className = 'flex flex-col items-start justify-center px-4 py-2 bg-[#2a2a2a] rounded-xl border border-white/5 hover:border-white/20 transition-colors text-left flex-1 min-w-[100px] md:min-w-[140px] max-w-[240px] h-[56px] relative group overflow-hidden';
summaryCard.setAttribute('data-tooltip', t('cinema.cameraSettings'));
// Dot indicator
const dot = document.createElement('div');
dot.className = 'absolute top-2 right-2 w-2 h-2 bg-primary rounded-full shadow-glow-sm';
summaryCard.appendChild(dot);
const summaryTitle = document.createElement('span');
summaryTitle.className = 'text-[10px] font-bold text-white uppercase truncate w-full tracking-wide';
summaryTitle.textContent = currentSettings.camera;
const summaryValue = document.createElement('span');
summaryValue.className = 'text-[10px] font-medium text-white/60 truncate w-full';
summaryValue.textContent = formatSummaryValue();
summaryCard.appendChild(summaryTitle);
summaryCard.appendChild(summaryValue);
summaryCard.onclick = openOverlay;
function formatSummaryValue() {
return `${currentSettings.lens}, ${currentSettings.focal}mm, ${currentSettings.aperture}`;
}
function updateSummaryCard() {
summaryTitle.textContent = currentSettings.camera;
summaryValue.textContent = formatSummaryValue();
}
// Generate Button
const generateBtn = document.createElement('button');
generateBtn.className = 'h-[56px] px-8 bg-[#22d3ee] text-black rounded-xl font-black text-xs uppercase hover:bg-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed';
generateBtn.setAttribute('data-tooltip', 'Generate cinema shot');
generateBtn.innerHTML = t('cinema.generateBtn');
rightGroup.appendChild(summaryCard);
rightGroup.appendChild(generateBtn);
promptBar.appendChild(rightGroup);
promptBarWrapper.appendChild(promptBar);
container.appendChild(promptBarWrapper);
// ==========================================
// 3B. CAMERA BUILDER PANEL (Collapsible)
// ==========================================
const cameraBuilderPanel = document.createElement('div');
cameraBuilderPanel.className = 'absolute bottom-8 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-4xl z-20';
cameraBuilderPanel.style.display = 'none'; // Hidden by default
const builderCard = document.createElement('div');
builderCard.className = 'bg-[#1a1a1a] border border-white/10 rounded-2xl p-4 shadow-3xl';
builderCard.innerHTML = `
<div class="flex items-center justify-between mb-4">
<h4 class="text-xs font-bold text-white">${t('cinema.cameraBuilder')}</h4>
<button id="close-builder-btn" class="text-white/40 hover:text-white transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">${t('cinema.camera')}</label>
<select id="builder-camera" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(CAMERA_MAP).map(c => `<option value="${c}" ${c === currentSettings.camera ? 'selected' : ''}>${c}</option>`).join('')}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">${t('cinema.lens')}</label>
<select id="builder-lens" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(LENS_MAP).map(l => `<option value="${l}" ${l === currentSettings.lens ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">${t('cinema.focal')}</label>
<select id="builder-focal" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(FOCAL_PERSPECTIVE).map(f => `<option value="${f}" ${f === currentSettings.focal ? 'selected' : ''}>${f}mm</option>`).join('')}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[10px] font-bold text-muted uppercase">${t('cinema.aperture')}</label>
<select id="builder-aperture" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50">
${Object.keys(APERTURE_EFFECT).map(a => `<option value="${a}" ${a === currentSettings.aperture ? 'selected' : ''}>${a}</option>`).join('')}
</select>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-muted uppercase">${t('cinema.preview')}</label>
<div id="builder-preview" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white text-xs min-h-[40px]"></div>
<button id="apply-builder-btn" class="px-4 py-2 bg-primary text-black rounded-lg text-xs font-bold hover:shadow-glow transition-all">
${t('cinema.useSetup')}
</button>
</div>
`;
cameraBuilderPanel.appendChild(builderCard);
container.appendChild(cameraBuilderPanel);
// Camera Builder toggle logic
cameraBuilderBtn.onclick = () => {
showCameraBuilder = !showCameraBuilder;
cameraBuilderPanel.style.display = showCameraBuilder ? 'block' : 'none';
if (showCameraBuilder) updateBuilderPreview();
};
const closeBuilderBtn = cameraBuilderPanel.querySelector('#close-builder-btn');
if (closeBuilderBtn) closeBuilderBtn.onclick = () => {
showCameraBuilder = false;
cameraBuilderPanel.style.display = 'none';
};
// Update builder preview
const updateBuilderPreview = () => {
const camera = builderCard.querySelector('#builder-camera')?.value || currentSettings.camera;
const lens = builderCard.querySelector('#builder-lens')?.value || currentSettings.lens;
const focal = parseInt(builderCard.querySelector('#builder-focal')?.value || currentSettings.focal);
const aperture = builderCard.querySelector('#builder-aperture')?.value || currentSettings.aperture;
const preview = buildNanoBananaPrompt('', camera, lens, focal, aperture);
const previewEl = builderCard.querySelector('#builder-preview');
if (previewEl) {
previewEl.textContent = preview || t('cinema.selectSettings');
}
};
// Builder event listeners
const builderCamera = builderCard.querySelector('#builder-camera');
const builderLens = builderCard.querySelector('#builder-lens');
const builderFocal = builderCard.querySelector('#builder-focal');
const builderAperture = builderCard.querySelector('#builder-aperture');
if (builderCamera) builderCamera.onchange = updateBuilderPreview;
if (builderLens) builderLens.onchange = updateBuilderPreview;
if (builderFocal) builderFocal.onchange = updateBuilderPreview;
if (builderAperture) builderAperture.onchange = updateBuilderPreview;
const applyBuilderBtn = builderCard.querySelector('#apply-builder-btn');
if (applyBuilderBtn) {
applyBuilderBtn.onclick = () => {
currentSettings.camera = builderCamera?.value || currentSettings.camera;
currentSettings.lens = builderLens?.value || currentSettings.lens;
currentSettings.focal = parseInt(builderFocal?.value || currentSettings.focal);
currentSettings.aperture = builderAperture?.value || currentSettings.aperture;
updateSummaryCard();
showCameraBuilder = false;
cameraBuilderPanel.style.display = 'none';
};
}
// ==========================================
// 3. HISTORY SIDEBAR
// ==========================================
const generationHistory = [];
// History Sidebar - VISIBLE BY DEFAULT (removed translate-x-full opacity-0)
const historySidebar = document.createElement('div');
historySidebar.className = 'fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500';
const historyLabel = document.createElement('div');
historyLabel.className = 'text-[9px] font-bold text-white/40 uppercase tracking-widest mb-2';
historyLabel.textContent = t('cinema.history');
historySidebar.appendChild(historyLabel);
const historyList = document.createElement('div');
historyList.className = 'flex flex-col gap-2 w-full px-2';
historySidebar.appendChild(historyList);
container.appendChild(historySidebar);
// ==========================================
// 4. CANVAS AREA (Result View)
// ==========================================
const canvas = document.createElement('div');
canvas.className = 'absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-30 opacity-0 pointer-events-none transition-all duration-1000 translate-y-10 scale-95 bg-black/90 backdrop-blur-3xl';
const imageContainer = document.createElement('div');
imageContainer.className = 'relative group max-w-full max-h-[70vh] flex items-center justify-center';
const resultImg = document.createElement('img');
resultImg.className = 'max-h-[60vh] max-w-[90vw] rounded-2xl shadow-2xl border border-white/10 object-contain';
imageContainer.appendChild(resultImg);
canvas.appendChild(imageContainer);
// Canvas Controls
const canvasControls = document.createElement('div');
canvasControls.className = 'mt-8 flex gap-3 opacity-0 transition-opacity delay-500 duration-500 justify-center';
const createActionBtn = (label, primary = false) => {
const btn = document.createElement('button');
btn.className = primary
? 'bg-[#22d3ee] text-black px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-wide hover:bg-white transition-colors shadow-glow-sm hover:scale-105 active:scale-95'
: 'bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide transition-all border border-white/5 backdrop-blur-lg text-white hover:border-white/20';
btn.textContent = label;
return btn;
};
const regenerateBtn = createActionBtn(t('cinema.regenerate'));
const downloadBtn = createActionBtn(t('cinema.download'), true);
const newPromptBtn = createActionBtn(t('cinema.newShot'));
canvasControls.appendChild(regenerateBtn);
canvasControls.appendChild(downloadBtn);
canvasControls.appendChild(newPromptBtn);
canvas.appendChild(canvasControls);
container.appendChild(canvas);
// --- History Logic ---
const renderHistory = () => {
historyList.innerHTML = '';
generationHistory.forEach((entry, idx) => {
const thumb = document.createElement('div');
thumb.className = `relative group/thumb cursor-pointer rounded-lg overflow-hidden border-2 transition-all duration-300 aspect-square ${idx === 0 ? 'border-[#22d3ee] shadow-glow-sm' : 'border-white/10 hover:border-white/30'}`;
thumb.innerHTML = `
<img src="${entry.url}" class="w-full h-full object-cover opacity-80 group-hover/thumb:opacity-100 transition-opacity">
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center">
<span class="text-[8px] font-bold text-white uppercase">${t('cinema.load')}</span>
</div>
`;
thumb.onclick = () => loadHistoryItem(entry, thumb);
historyList.appendChild(thumb);
});
};
const addToHistory = (entry) => {
generationHistory.unshift(entry);
localStorage.setItem('cinema_history', JSON.stringify(generationHistory.slice(0, 50)));
renderHistory();
};
const loadHistoryItem = (entry, thumbElement) => {
// Restore Settings
if (entry.settings) {
currentSettings.camera = entry.settings.camera;
currentSettings.lens = entry.settings.lens;
currentSettings.focal = entry.settings.focal;
currentSettings.aperture = entry.settings.aperture;
currentSettings.aspect_ratio = entry.settings.aspect_ratio;
// Update UI elements
textarea.value = entry.settings.prompt || '';
updateSummaryCard();
updateArBtn();
updateResBtn(entry.settings.resolution || '2K');
}
showCanvas(entry.url);
// Highlight active history item
if (thumbElement) {
historyList.querySelectorAll('div').forEach(t => {
t.classList.remove('border-[#22d3ee]', 'shadow-glow-sm');
t.classList.add('border-white/10');
});
thumbElement.classList.remove('border-white/10');
thumbElement.classList.add('border-[#22d3ee]', 'shadow-glow-sm');
}
};
const showCanvas = (url) => {
resultImg.src = url;
// Hide Input UI
heroSection.classList.add('opacity-0', 'pointer-events-none', 'scale-95');
promptBarWrapper.classList.add('opacity-0', 'pointer-events-none', 'translate-y-20');
// Show Canvas
canvas.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-10', 'scale-95');
canvas.classList.add('opacity-100', 'translate-y-0', 'scale-100');
canvasControls.classList.remove('opacity-0');
canvasControls.classList.add('opacity-100');
};
const resetToPrompt = () => {
// Hide Canvas
canvas.classList.add('opacity-0', 'pointer-events-none', 'translate-y-10', 'scale-95');
canvas.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
// Show Input UI
heroSection.classList.remove('opacity-0', 'pointer-events-none', 'scale-95');
promptBarWrapper.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-20');
// Clear prompt for new shot?
textarea.value = '';
textarea.focus();
};
// Load saved history
try {
const saved = JSON.parse(localStorage.getItem('cinema_history') || '[]');
if (saved.length > 0) {
saved.forEach(e => generationHistory.push(e));
renderHistory();
}
} catch (e) { }
// Actions
newPromptBtn.onclick = resetToPrompt;
regenerateBtn.onclick = () => {
resetToPrompt();
setTimeout(() => {
generateBtn.click();
}, 300);
};
downloadBtn.onclick = async () => {
try {
const response = await fetch(resultImg.src);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `cinema-shot-${Date.now()}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err) {
window.open(resultImg.src, '_blank');
}
};
// ==========================================
// 5. GENERATION LOGIC UPDATE
// ==========================================
generateBtn.onclick = async () => {
const basePrompt = textarea.value.trim();
if (!basePrompt) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => generateBtn.click());
return;
}
generateBtn.disabled = true;
generateBtn.innerHTML = t('cinema.shooting');
// Compile Prompt
const finalPrompt = buildNanoBananaPrompt(
basePrompt,
currentSettings.camera,
currentSettings.lens,
currentSettings.focal,
currentSettings.aperture
);
try {
const res = await muapi.generateImage({
model: 'nano-banana-pro',
prompt: finalPrompt,
aspect_ratio: currentSettings.aspect_ratio,
resolution: (resBtn.dataset.value || '1k').toLowerCase(),
negative_prompt: "blurry, low quality, distortion, bad composition"
});
if (res && res.url) {
// Save to history
addToHistory({
url: res.url,
timestamp: Date.now(),
settings: {
prompt: basePrompt,
...currentSettings,
resolution: resBtn.dataset.value
}
});
showCanvas(res.url);
} else {
throw new Error('No Data');
}
} catch (e) {
console.error(e);
alert(t('cinema.generationFailed') + e.message);
} finally {
generateBtn.disabled = false;
generateBtn.innerHTML = t('cinema.generateBtn');
}
};
return container;
}
+98
View File
@@ -0,0 +1,98 @@
import { SettingsModal } from './SettingsModal.js';
import { t, getLang, setLang } from '../lib/i18n.js';
export function Header(navigate) {
const header = document.createElement('header');
header.className = 'w-full flex flex-col z-50 sticky top-0';
// 2. Main Navigation Bar
const navBar = document.createElement('div');
navBar.className = 'w-full h-16 bg-black flex items-center justify-between px-4 md:px-6 border-b border-white/5 backdrop-blur-md bg-opacity-95';
const leftPart = document.createElement('div');
leftPart.className = 'flex items-center gap-8';
// Logo
const logoContainer = document.createElement('div');
logoContainer.className = 'cursor-pointer hover:scale-110 transition-transform';
logoContainer.innerHTML = `
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center p-1.5 shadow-lg">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="black"/>
<path d="M2 17L12 22L22 17" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
`;
const menu = document.createElement('nav');
menu.className = 'hidden lg:flex items-center gap-6 text-[13px] font-bold text-secondary';
const items = [
{ label: t('nav.image'), page: 'image' },
{ label: t('nav.video'), page: 'video' },
{ label: t('nav.lipsync'), page: 'lipsync' },
{ label: t('nav.cinema'), page: 'cinema' },
{ label: t('nav.workflows'), page: 'workflows' },
{ label: t('nav.agents'), page: 'agents' },
{ label: t('nav.mcpcli'), page: 'mcp-cli' },
];
items.forEach(({ label, page }, idx) => {
const link = document.createElement('a');
link.textContent = label;
link.className = `hover:text-white transition-all cursor-pointer relative group ${idx === 0 ? 'text-white' : ''}`;
if (idx === 0) {
const dot = document.createElement('div');
dot.className = 'absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full';
link.appendChild(dot);
}
link.onclick = () => {
Array.from(menu.children).forEach(child => child.classList.remove('text-white'));
link.classList.add('text-white');
navigate(page);
};
menu.appendChild(link);
});
leftPart.appendChild(logoContainer);
leftPart.appendChild(menu);
const rightPart = document.createElement('div');
rightPart.className = 'flex items-center gap-4';
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 = 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"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>${t('nav.settings')}</span>
`;
settingsBtn.onclick = () => {
document.body.appendChild(SettingsModal());
};
// Language toggle button
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-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);
navBar.appendChild(leftPart);
navBar.appendChild(rightPart);
header.appendChild(navBar);
return header;
}
File diff suppressed because it is too large Load Diff
+732
View File
@@ -0,0 +1,732 @@
import { muapi } from '../lib/muapi.js';
import { lipsyncModels, imageLipSyncModels, videoLipSyncModels, getLipSyncModelById, getResolutionsForLipSyncModel } from '../lib/models.js';
import { AuthModal } from './AuthModal.js';
import { t } from '../lib/i18n.js';
import { createUploadPicker } from './UploadPicker.js';
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
export function LipSyncStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden';
// --- State ---
// 'image' mode: portrait image + audio → video
// 'video' mode: existing video + audio → lipsync video
let inputMode = 'image';
let selectedModel = imageLipSyncModels[0].id;
let selectedResolution = imageLipSyncModels[0].inputs?.resolution?.default || '480p';
let uploadedImageUrl = null;
let uploadedVideoUrl = null;
let uploadedAudioUrl = null;
let dropdownOpen = null;
const getCurrentModels = () => inputMode === 'image' ? imageLipSyncModels : videoLipSyncModels;
const getCurrentModel = () => lipsyncModels.find(m => m.id === selectedModel);
// ==========================================
// 1. HERO SECTION
// ==========================================
const hero = document.createElement('div');
hero.className = 'flex flex-col items-center mb-10 md:mb-20 animate-fade-in-up transition-all duration-700';
hero.innerHTML = `
<div class="mb-10 relative group">
<div class="absolute inset-0 bg-primary/20 blur-[100px] rounded-full opacity-40 group-hover:opacity-70 transition-opacity duration-1000"></div>
<div class="relative w-24 h-24 md:w-32 md:h-32 bg-teal-900/40 rounded-3xl flex items-center justify-center border border-white/5 overflow-hidden">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="text-primary opacity-20 absolute -right-4 -bottom-4">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center border border-primary/20 shadow-glow relative z-10">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-primary">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</div>
<div class="absolute top-4 right-4 text-primary animate-pulse">🎙</div>
</div>
</div>
<h1 class="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">${t('lipsync.title')}</h1>
<p class="text-secondary text-sm font-medium tracking-wide opacity-60">${t('lipsync.subtitle')}</p>
`;
container.appendChild(hero);
// ==========================================
// 2. INPUT BAR
// ==========================================
const promptWrapper = document.createElement('div');
promptWrapper.className = 'w-full max-w-4xl relative z-40 animate-fade-in-up';
promptWrapper.style.animationDelay = '0.2s';
const bar = document.createElement('div');
bar.className = 'w-full bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-5 flex flex-col gap-3 md:gap-5 shadow-3xl';
// --- Mode Toggle (Image vs Video) ---
const modeToggleRow = document.createElement('div');
modeToggleRow.className = 'flex items-center gap-2 px-2';
const modeLabel = document.createElement('span');
modeLabel.className = 'text-xs text-muted font-bold uppercase tracking-widest mr-2';
modeLabel.textContent = t('lipsync.input');
const imageModeBtn = document.createElement('button');
imageModeBtn.type = 'button';
imageModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-primary bg-primary/10 text-primary';
imageModeBtn.textContent = t('lipsync.portraitImage');
const videoModeBtn = document.createElement('button');
videoModeBtn.type = 'button';
videoModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-white/10 text-muted hover:border-white/30 hover:text-white';
videoModeBtn.textContent = t('lipsync.video');
modeToggleRow.appendChild(modeLabel);
modeToggleRow.appendChild(imageModeBtn);
modeToggleRow.appendChild(videoModeBtn);
bar.appendChild(modeToggleRow);
// --- Uploads Row ---
const uploadsRow = document.createElement('div');
uploadsRow.className = 'flex items-start gap-3 px-2';
// ── Image Upload — uses createUploadPicker (same as VideoStudio) ──
const imagePicker = createUploadPicker({
anchorContainer: container,
onSelect: ({ url }) => {
uploadedImageUrl = url;
imageStatusLabel.textContent = t('lipsync.imageReady');
imageStatusLabel.className = 'text-primary';
},
onClear: () => {
uploadedImageUrl = null;
imageStatusLabel.textContent = t('lipsync.noImage');
imageStatusLabel.className = 'text-muted';
}
});
// Size the trigger to match our other buttons
imagePicker.trigger.className = imagePicker.trigger.className
.replace('w-10 h-10', 'w-14 h-14')
.replace('mt-1.5', '');
container.appendChild(imagePicker.panel);
// ── Video Upload Button (VideoStudio pattern — separate state divs, file input inside btn) ──
const videoFileInput = document.createElement('input');
videoFileInput.type = 'file';
videoFileInput.accept = 'video/*';
videoFileInput.className = 'hidden';
const videoPickerBtn = document.createElement('button');
videoPickerBtn.type = 'button';
videoPickerBtn.title = 'Upload source video';
videoPickerBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden hidden bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group';
const videoIconEl = document.createElement('div');
videoIconEl.className = 'flex flex-col items-center justify-center gap-1 w-full h-full';
videoIconEl.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted group-hover:text-primary transition-colors"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg><span class="text-[9px] text-muted group-hover:text-primary font-bold">VIDEO</span>`;
const videoSpinnerEl = document.createElement('div');
videoSpinnerEl.className = 'hidden items-center justify-center w-full h-full';
videoSpinnerEl.innerHTML = `<span class="animate-spin text-primary text-sm">◌</span>`;
const videoReadyEl = document.createElement('div');
videoReadyEl.className = 'hidden flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10';
videoReadyEl.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-primary"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg><span class="text-[9px] text-primary font-bold">READY</span>`;
videoPickerBtn.appendChild(videoFileInput);
videoPickerBtn.appendChild(videoIconEl);
videoPickerBtn.appendChild(videoSpinnerEl);
videoPickerBtn.appendChild(videoReadyEl);
const showVideoIcon = () => {
videoIconEl.classList.replace('hidden', 'flex');
videoSpinnerEl.classList.add('hidden'); videoSpinnerEl.classList.remove('flex');
videoReadyEl.classList.add('hidden'); videoReadyEl.classList.remove('flex');
videoPickerBtn.classList.remove('border-primary/60'); videoPickerBtn.classList.add('border-white/10');
videoPickerBtn.title = 'Upload source video';
mediaStatusLabel.textContent = t('lipsync.noVideo'); mediaStatusLabel.className = 'text-muted';
};
const showVideoSpinner = () => {
videoIconEl.classList.add('hidden'); videoIconEl.classList.remove('flex');
videoSpinnerEl.classList.replace('hidden', 'flex');
videoReadyEl.classList.add('hidden'); videoReadyEl.classList.remove('flex');
};
const showVideoReady = (name) => {
videoIconEl.classList.add('hidden'); videoIconEl.classList.remove('flex');
videoSpinnerEl.classList.add('hidden'); videoSpinnerEl.classList.remove('flex');
videoReadyEl.classList.replace('hidden', 'flex');
videoPickerBtn.classList.remove('border-white/10'); videoPickerBtn.classList.add('border-primary/60');
videoPickerBtn.title = `${name} — click to clear`;
mediaStatusLabel.textContent = `${name}`; mediaStatusLabel.className = 'text-primary';
};
videoPickerBtn.onclick = (e) => {
e.stopPropagation();
if (uploadedVideoUrl) { uploadedVideoUrl = null; showVideoIcon(); return; }
videoFileInput.click();
};
videoFileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) { AuthModal(() => videoFileInput.click()); return; }
showVideoSpinner();
try {
uploadedVideoUrl = await muapi.uploadFile(file);
showVideoReady(file.name);
} catch (err) { showVideoIcon(); alert(`Video upload failed: ${err.message}`); }
videoFileInput.value = '';
};
// ── Audio Upload Button (same pattern as video) ──
const audioFileInput = document.createElement('input');
audioFileInput.type = 'file';
audioFileInput.accept = 'audio/*';
audioFileInput.className = 'hidden';
const audioPickerBtn = document.createElement('button');
audioPickerBtn.type = 'button';
audioPickerBtn.title = 'Upload audio file';
audioPickerBtn.className = 'flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group';
const audioIconEl = document.createElement('div');
audioIconEl.className = 'flex flex-col items-center justify-center gap-1 w-full h-full';
audioIconEl.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted group-hover:text-primary transition-colors"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg><span class="text-[9px] text-muted group-hover:text-primary font-bold">AUDIO</span>`;
const audioSpinnerEl = document.createElement('div');
audioSpinnerEl.className = 'hidden items-center justify-center w-full h-full';
audioSpinnerEl.innerHTML = `<span class="animate-spin text-primary text-sm">◌</span>`;
const audioReadyEl = document.createElement('div');
audioReadyEl.className = 'hidden flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10';
audioReadyEl.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-primary"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg><span class="text-[9px] text-primary font-bold">READY</span>`;
audioPickerBtn.appendChild(audioFileInput);
audioPickerBtn.appendChild(audioIconEl);
audioPickerBtn.appendChild(audioSpinnerEl);
audioPickerBtn.appendChild(audioReadyEl);
const showAudioIcon = () => {
audioIconEl.classList.replace('hidden', 'flex');
audioSpinnerEl.classList.add('hidden'); audioSpinnerEl.classList.remove('flex');
audioReadyEl.classList.add('hidden'); audioReadyEl.classList.remove('flex');
audioPickerBtn.classList.remove('border-primary/60'); audioPickerBtn.classList.add('border-white/10');
audioPickerBtn.title = 'Upload audio file';
audioStatusLabel.textContent = t('lipsync.noAudio'); audioStatusLabel.className = 'text-muted';
};
const showAudioSpinner = () => {
audioIconEl.classList.add('hidden'); audioIconEl.classList.remove('flex');
audioSpinnerEl.classList.replace('hidden', 'flex');
audioReadyEl.classList.add('hidden'); audioReadyEl.classList.remove('flex');
};
const showAudioReady = (name) => {
audioIconEl.classList.add('hidden'); audioIconEl.classList.remove('flex');
audioSpinnerEl.classList.add('hidden'); audioSpinnerEl.classList.remove('flex');
audioReadyEl.classList.replace('hidden', 'flex');
audioPickerBtn.classList.remove('border-white/10'); audioPickerBtn.classList.add('border-primary/60');
audioPickerBtn.title = `${name} — click to clear`;
audioStatusLabel.textContent = `${name}`; audioStatusLabel.className = 'text-primary';
};
audioPickerBtn.onclick = (e) => {
e.stopPropagation();
if (uploadedAudioUrl) { uploadedAudioUrl = null; showAudioIcon(); return; }
audioFileInput.click();
};
audioFileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) { AuthModal(() => audioFileInput.click()); return; }
showAudioSpinner();
try {
uploadedAudioUrl = await muapi.uploadFile(file);
showAudioReady(file.name);
} catch (err) { showAudioIcon(); alert(`Audio upload failed: ${err.message}`); }
audioFileInput.value = '';
};
// ── Prompt Textarea ──
const textarea = document.createElement('textarea');
textarea.placeholder = t('lipsync.promptPlaceholder');
textarea.className = 'flex-1 bg-transparent text-white placeholder-muted/50 text-sm resize-none outline-none min-h-[56px] leading-relaxed pt-1';
textarea.rows = 2;
uploadsRow.appendChild(imagePicker.trigger);
uploadsRow.appendChild(videoPickerBtn);
uploadsRow.appendChild(audioPickerBtn);
uploadsRow.appendChild(textarea);
bar.appendChild(uploadsRow);
// ── Status labels ──
const statusRow = document.createElement('div');
statusRow.className = 'flex items-center gap-3 px-2 text-xs text-muted';
// mediaStatusLabel: shows image or video status depending on mode
const mediaStatusLabel = document.createElement('span');
mediaStatusLabel.className = 'text-muted';
mediaStatusLabel.textContent = t('lipsync.noImage');
const imageStatusLabel = mediaStatusLabel; // alias used in imagePicker callbacks
const audioStatusLabel = document.createElement('span');
audioStatusLabel.className = 'text-muted';
audioStatusLabel.textContent = t('lipsync.noAudio');
statusRow.appendChild(mediaStatusLabel);
statusRow.appendChild(document.createTextNode(' · '));
statusRow.appendChild(audioStatusLabel);
bar.appendChild(statusRow);
// ── Bottom Controls Row ──
const bottomRow = document.createElement('div');
bottomRow.className = 'flex items-center gap-2 md:gap-3 flex-wrap px-2';
// Model selector
const modelBtn = document.createElement('button');
modelBtn.id = 'ls-model-btn';
modelBtn.type = 'button';
modelBtn.className = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-primary/40 transition-all text-xs font-bold text-white group';
modelBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-primary"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg><span id="ls-model-btn-label">${getCurrentModels()[0].name}</span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-muted group-hover:text-white transition-colors"><polyline points="6 9 12 15 18 9"/></svg>`;
// Resolution selector
const resolutionBtn = document.createElement('button');
resolutionBtn.id = 'ls-resolution-btn';
resolutionBtn.type = 'button';
resolutionBtn.className = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-primary/40 transition-all text-xs font-bold text-white group';
resolutionBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-primary"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg><span id="ls-resolution-btn-label">${selectedResolution}</span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-muted group-hover:text-white transition-colors"><polyline points="6 9 12 15 18 9"/></svg>`;
// Generate button
const generateBtn = document.createElement('button');
generateBtn.id = 'ls-generate-btn';
generateBtn.type = 'button';
generateBtn.className = 'ml-auto px-6 py-2.5 bg-primary text-black font-black text-sm rounded-2xl hover:scale-105 active:scale-95 transition-all shadow-glow disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
generateBtn.textContent = t('common.generate');
bottomRow.appendChild(modelBtn);
bottomRow.appendChild(resolutionBtn);
bottomRow.appendChild(generateBtn);
bar.appendChild(bottomRow);
promptWrapper.appendChild(bar);
container.appendChild(promptWrapper);
// ==========================================
// 3. DROPDOWN SYSTEM
// ==========================================
const dropdown = document.createElement('div');
dropdown.className = 'hidden fixed z-[100] bg-[#111] border border-white/10 rounded-2xl shadow-3xl p-2 min-w-[200px] max-h-[400px] overflow-y-auto custom-scrollbar';
dropdown.id = 'ls-dropdown';
const closeDropdown = (e) => {
if (!e || (!dropdown.contains(e.target) && !e.target.closest('[id^="ls-"]'))) {
dropdown.classList.add('hidden');
dropdownOpen = null;
}
};
const populateDropdown = (type) => {
dropdown.innerHTML = '';
if (type === 'model') {
const models = getCurrentModels();
models.forEach(m => {
const item = document.createElement('button');
item.type = 'button';
item.className = `w-full text-left px-4 py-2.5 rounded-xl text-sm transition-all hover:bg-white/10 ${m.id === selectedModel ? 'text-primary font-bold bg-primary/5' : 'text-white font-medium'}`;
item.innerHTML = `<div>${m.name}</div><div class="text-xs text-muted mt-0.5">${m.description?.slice(0, 60)}...</div>`;
item.onclick = () => {
selectedModel = m.id;
document.getElementById('ls-model-btn-label').textContent = m.name;
const resolutions = getResolutionsForLipSyncModel(selectedModel);
if (resolutions.length > 0) {
selectedResolution = m.inputs?.resolution?.default || resolutions[0];
document.getElementById('ls-resolution-btn-label').textContent = selectedResolution;
resolutionBtn.classList.remove('hidden');
} else {
resolutionBtn.classList.add('hidden');
}
textarea.style.display = m.hasPrompt ? '' : 'none';
closeDropdown();
};
dropdown.appendChild(item);
});
} else if (type === 'resolution') {
const resolutions = getResolutionsForLipSyncModel(selectedModel);
resolutions.forEach(r => {
const item = document.createElement('button');
item.type = 'button';
item.className = `w-full text-left px-4 py-2.5 rounded-xl text-sm transition-all hover:bg-white/10 ${r === selectedResolution ? 'text-primary font-bold bg-primary/5' : 'text-white font-medium'}`;
item.textContent = r;
item.onclick = () => {
selectedResolution = r;
document.getElementById('ls-resolution-btn-label').textContent = r;
closeDropdown();
};
dropdown.appendChild(item);
});
}
};
const openDropdown = (type, anchorBtn) => {
dropdownOpen = type;
// Populate and temporarily show off-screen to measure height
populateDropdown(type);
dropdown.style.top = '-9999px';
dropdown.style.bottom = 'auto';
dropdown.classList.remove('hidden');
const ddHeight = dropdown.offsetHeight;
const rect = anchorBtn.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
if (spaceBelow >= ddHeight || spaceBelow >= spaceAbove) {
dropdown.style.top = `${rect.bottom + 8}px`;
dropdown.style.bottom = 'auto';
dropdown.style.maxHeight = `${Math.max(150, spaceBelow - 8)}px`;
} else {
dropdown.style.top = 'auto';
dropdown.style.bottom = `${window.innerHeight - rect.top + 8}px`;
dropdown.style.maxHeight = `${Math.max(150, spaceAbove - 8)}px`;
}
dropdown.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`;
};
modelBtn.onclick = (e) => { e.stopPropagation(); if (dropdownOpen === 'model') { closeDropdown(); } else { openDropdown('model', modelBtn); } };
resolutionBtn.onclick = (e) => { e.stopPropagation(); if (dropdownOpen === 'resolution') { closeDropdown(); } else { openDropdown('resolution', resolutionBtn); } };
window.addEventListener('click', closeDropdown);
container.appendChild(dropdown);
// ==========================================
// 4. MODE SWITCHING LOGIC
// ==========================================
const updateUIForMode = () => {
if (inputMode === 'image') {
imageModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-primary bg-primary/10 text-primary';
videoModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-white/10 text-muted hover:border-white/30 hover:text-white';
imagePicker.trigger.classList.remove('hidden');
videoPickerBtn.classList.add('hidden');
mediaStatusLabel.textContent = uploadedImageUrl ? t('lipsync.imageReady') : t('lipsync.noImage');
mediaStatusLabel.className = uploadedImageUrl ? 'text-primary' : 'text-muted';
} else {
videoModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-primary bg-primary/10 text-primary';
imageModeBtn.className = 'px-4 py-1.5 rounded-xl text-xs font-bold transition-all border border-white/10 text-muted hover:border-white/30 hover:text-white';
videoPickerBtn.classList.remove('hidden');
imagePicker.trigger.classList.add('hidden');
mediaStatusLabel.textContent = uploadedVideoUrl ? t('lipsync.videoReady') : t('lipsync.noVideo');
mediaStatusLabel.className = uploadedVideoUrl ? 'text-primary' : 'text-muted';
}
// Switch to first model of new mode
const models = getCurrentModels();
selectedModel = models[0].id;
document.getElementById('ls-model-btn-label').textContent = models[0].name;
// Update resolution
const resolutions = getResolutionsForLipSyncModel(selectedModel);
if (resolutions.length > 0) {
selectedResolution = models[0].inputs?.resolution?.default || resolutions[0];
document.getElementById('ls-resolution-btn-label').textContent = selectedResolution;
resolutionBtn.classList.remove('hidden');
} else {
resolutionBtn.classList.add('hidden');
}
// Show/hide prompt
textarea.style.display = models[0].hasPrompt ? '' : 'none';
};
imageModeBtn.onclick = () => {
if (inputMode === 'image') return;
inputMode = 'image';
uploadedVideoUrl = null;
showVideoIcon();
updateUIForMode();
};
videoModeBtn.onclick = () => {
if (inputMode === 'video') return;
inputMode = 'video';
uploadedImageUrl = null;
imagePicker.reset();
updateUIForMode();
};
// Hide resolution if first model has none
if (getResolutionsForLipSyncModel(selectedModel).length === 0) {
resolutionBtn.classList.add('hidden');
}
// ==========================================
// 6. CANVAS AREA + HISTORY
// ==========================================
const generationHistory = [];
const historySidebar = document.createElement('div');
historySidebar.className = 'fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500 translate-x-full opacity-0';
historySidebar.id = 'lipsync-history-sidebar';
const historyLabel = document.createElement('div');
historyLabel.className = 'text-[9px] font-bold text-muted uppercase tracking-widest mb-2';
historyLabel.textContent = t('lipsync.history');
historySidebar.appendChild(historyLabel);
const historyList = document.createElement('div');
historyList.className = 'flex flex-col gap-2 w-full px-2';
historySidebar.appendChild(historyList);
container.appendChild(historySidebar);
// Main canvas
const canvas = document.createElement('div');
canvas.className = 'absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-10 opacity-0 pointer-events-none transition-all duration-1000 translate-y-10 scale-95';
const videoContainer = document.createElement('div');
videoContainer.className = 'relative group';
const resultVideo = document.createElement('video');
resultVideo.className = 'max-h-[60vh] max-w-[80vw] rounded-3xl shadow-3xl border border-white/10 interactive-glow object-contain';
resultVideo.controls = true;
resultVideo.loop = true;
resultVideo.autoplay = true;
resultVideo.muted = false;
resultVideo.playsInline = true;
videoContainer.appendChild(resultVideo);
const canvasControls = document.createElement('div');
canvasControls.className = 'mt-6 flex gap-3 opacity-0 transition-opacity delay-500 duration-500 justify-center';
const regenerateBtn = document.createElement('button');
regenerateBtn.className = 'bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white';
regenerateBtn.textContent = t('lipsync.regenerate');
const downloadBtn = document.createElement('button');
downloadBtn.className = 'bg-primary text-black px-6 py-2.5 rounded-2xl text-xs font-bold transition-all shadow-glow active:scale-95';
downloadBtn.textContent = t('lipsync.download');
const newBtn = document.createElement('button');
newBtn.className = 'bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white';
newBtn.textContent = t('lipsync.new');
canvasControls.appendChild(regenerateBtn);
canvasControls.appendChild(downloadBtn);
canvasControls.appendChild(newBtn);
canvas.appendChild(videoContainer);
canvas.appendChild(canvasControls);
container.appendChild(canvas);
const showVideoInCanvas = (videoUrl) => {
hero.classList.add('hidden');
promptWrapper.classList.add('hidden');
resultVideo.src = videoUrl;
resultVideo.onloadeddata = () => {
canvas.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-10', 'scale-95');
canvas.classList.add('opacity-100', 'translate-y-0', 'scale-100');
canvasControls.classList.remove('opacity-0');
canvasControls.classList.add('opacity-100');
};
};
const addToHistory = (entry) => {
generationHistory.unshift(entry);
localStorage.setItem('lipsync_history', JSON.stringify(generationHistory.slice(0, 30)));
historySidebar.classList.remove('translate-x-full', 'opacity-0');
historySidebar.classList.add('translate-x-0', 'opacity-100');
renderHistory();
};
const renderHistory = () => {
historyList.innerHTML = '';
generationHistory.forEach((entry, idx) => {
const thumb = document.createElement('div');
thumb.className = `relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${idx === 0 ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`;
thumb.innerHTML = `
<video src="${entry.url}" preload="metadata" muted class="w-full aspect-square object-cover"></video>
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center">
<button class="hist-download p-1.5 bg-primary rounded-lg text-black hover:scale-110 transition-transform" title="Download">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</button>
</div>
`;
thumb.onclick = (e) => {
if (e.target.closest('.hist-download')) { downloadFile(entry.url, `lipsync-${entry.id || idx}.mp4`); return; }
showVideoInCanvas(entry.url);
historyList.querySelectorAll('div').forEach(t => { t.classList.remove('border-primary', 'shadow-glow'); t.classList.add('border-white/10'); });
thumb.classList.remove('border-white/10');
thumb.classList.add('border-primary', 'shadow-glow');
};
historyList.appendChild(thumb);
});
};
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'); }
};
// Load history
try {
const saved = JSON.parse(localStorage.getItem('lipsync_history') || '[]');
if (saved.length > 0) {
saved.forEach(e => generationHistory.push(e));
historySidebar.classList.remove('translate-x-full', 'opacity-0');
historySidebar.classList.add('translate-x-0', 'opacity-100');
renderHistory();
}
} catch { /* ignore */ }
// Resume pending jobs
(async () => {
const pending = getPendingJobs('lipsync');
if (!pending.length) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) return;
const banner = document.createElement('div');
banner.className = 'fixed top-4 left-1/2 -translate-x-1/2 z-[200] bg-[#111] border border-white/10 text-white text-sm px-5 py-3 rounded-2xl shadow-xl flex items-center gap-3';
banner.innerHTML = `<span class="animate-spin text-primary">◌</span> <span class="banner-text">Resuming ${pending.length} pending generation${pending.length > 1 ? 's' : ''}…</span>`;
document.body.appendChild(banner);
let remaining = pending.length;
pending.forEach(async (job) => {
const elapsedAttempts = Math.floor((Date.now() - job.submittedAt) / job.interval);
const attemptsLeft = Math.max(1, job.maxAttempts - elapsedAttempts);
try {
const result = await muapi.pollForResult(job.requestId, apiKey, attemptsLeft, job.interval);
const url = result.outputs?.[0] || result.url || result.output?.url;
if (url) addToHistory({ id: job.requestId, url, ...job.historyMeta, timestamp: new Date().toISOString() });
} catch (e) { console.warn('[LipSyncStudio] Pending job failed:', job.requestId, e.message); }
finally {
removePendingJob(job.requestId);
remaining--;
if (remaining === 0) banner.remove();
else banner.querySelector('.banner-text').textContent = `Resuming ${remaining} pending generation${remaining > 1 ? 's' : ''}`;
}
});
})();
// ==========================================
// 7. CANVAS BUTTON HANDLERS
// ==========================================
downloadBtn.onclick = () => {
const current = resultVideo.src;
if (current) {
const entry = generationHistory.find(e => e.url === current);
downloadFile(current, `lipsync-${entry?.id || 'clip'}.mp4`);
}
};
regenerateBtn.onclick = () => generateBtn.click();
newBtn.onclick = () => {
canvas.classList.add('opacity-0', 'pointer-events-none', 'translate-y-10', 'scale-95');
canvas.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
canvasControls.classList.add('opacity-0');
canvasControls.classList.remove('opacity-100');
hero.classList.remove('hidden', 'opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
promptWrapper.classList.remove('hidden', 'opacity-40');
textarea.value = '';
// Reset uploads
imagePicker.reset();
uploadedImageUrl = null;
uploadedVideoUrl = null;
uploadedAudioUrl = null;
showVideoIcon();
showAudioIcon();
mediaStatusLabel.textContent = inputMode === 'image' ? t('lipsync.noImage') : t('lipsync.noVideo');
mediaStatusLabel.className = 'text-muted';
audioStatusLabel.textContent = t('lipsync.noAudio');
audioStatusLabel.className = 'text-muted';
textarea.focus();
};
// ==========================================
// 8. GENERATION LOGIC
// ==========================================
generateBtn.onclick = async () => {
const model = getCurrentModel();
const prompt = textarea.value.trim();
// Validation
if (!uploadedAudioUrl) {
alert(t('lipsync.noAudioAlert'));
return;
}
if (inputMode === 'image' && !uploadedImageUrl) {
alert(t('lipsync.noImageAlert'));
return;
}
if (inputMode === 'video' && !uploadedVideoUrl) {
alert(t('lipsync.noVideoAlert'));
return;
}
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) { AuthModal(() => generateBtn.click()); return; }
hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${t('common.generating')}`;
let hadError = false;
let capturedRequestId = null;
const historyMeta = { prompt, model: selectedModel };
const onRequestId = (rid) => {
capturedRequestId = rid;
savePendingJob({ requestId: rid, studioType: 'lipsync', historyMeta, maxAttempts: 900, interval: 2000, submittedAt: Date.now() });
};
try {
const lipsyncParams = {
model: selectedModel,
audio_url: uploadedAudioUrl,
onRequestId
};
if (inputMode === 'image') {
lipsyncParams.image_url = uploadedImageUrl;
} else {
lipsyncParams.video_url = uploadedVideoUrl;
}
if (prompt && model?.hasPrompt) lipsyncParams.prompt = prompt;
const resolutions = getResolutionsForLipSyncModel(selectedModel);
if (resolutions.length > 0) lipsyncParams.resolution = selectedResolution;
if (model?.hasSeed) lipsyncParams.seed = -1;
const res = await muapi.processLipSync(lipsyncParams);
console.log('[LipSyncStudio] Response:', res);
if (res && res.url) {
if (capturedRequestId) removePendingJob(capturedRequestId);
const genId = res.id || capturedRequestId || Date.now().toString();
addToHistory({ id: genId, url: res.url, prompt, model: selectedModel, timestamp: new Date().toISOString() });
showVideoInCanvas(res.url);
} else {
throw new Error('No video URL returned by API');
}
} catch (e) {
hadError = true;
if (capturedRequestId) removePendingJob(capturedRequestId);
console.error(e);
hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.innerHTML = `Error: ${e.message.slice(0, 60)}`;
setTimeout(() => { generateBtn.innerHTML = t('common.generate'); }, 4000);
} finally {
generateBtn.disabled = false;
if (!hadError) generateBtn.innerHTML = t('common.generate');
}
};
return container;
}
+413
View File
@@ -0,0 +1,413 @@
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
import { t, tf } from '../lib/i18n.js';
// ─── Icons ────────────────────────────────────────────────────────────────────
const DownloadIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`;
const TrashIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>`;
const CheckIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtGB(gb) {
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(gb * 1024).toFixed(0)} MB`;
}
function tagEl(text) {
const span = document.createElement('span');
span.className = 'px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted';
span.textContent = text;
return span;
}
// ─── Binary Status Bar ────────────────────────────────────────────────────────
function BinaryStatusBar(onStatusChange) {
const bar = document.createElement('div');
bar.className = 'flex items-center justify-between gap-3 p-3 rounded-xl bg-white/3 border border-white/5';
const label = document.createElement('div');
label.className = 'flex flex-col gap-0.5';
label.innerHTML = `
<span class="text-xs font-bold text-white">sd.cpp inference engine</span>
<span id="binary-status-text" class="text-[11px] text-muted">${t('localModels.checking')}</span>
`;
const btn = document.createElement('button');
btn.id = 'binary-action-btn';
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold transition-all hidden';
btn.textContent = t('localModels.installEngine');
bar.appendChild(label);
bar.appendChild(btn);
const progressBar = document.createElement('div');
progressBar.className = 'h-1 rounded-full bg-white/5 mt-2 hidden overflow-hidden';
progressBar.id = 'binary-progress-bar';
progressBar.innerHTML = `<div id="binary-progress-fill" class="h-full bg-primary transition-all" style="width:0%"></div>`;
bar.appendChild(progressBar);
const refresh = async () => {
const status = await localAI.getBinaryStatus();
const text = bar.querySelector('#binary-status-text');
if (status.exists) {
text.textContent = t('localModels.installed');
text.className = 'text-[11px] text-green-400';
btn.classList.add('hidden');
} else {
text.textContent = t('localModels.notInstalled');
text.className = 'text-[11px] text-yellow-400';
btn.textContent = t('localModels.installEngine');
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black transition-all';
btn.classList.remove('hidden');
}
if (onStatusChange) onStatusChange(status.exists);
};
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = t('localModels.downloading');
progressBar.classList.remove('hidden');
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
if (id !== '__binary__') return;
const fill = document.getElementById('binary-progress-fill');
const text = bar.querySelector('#binary-status-text');
if (fill) fill.style.width = `${Math.round(progress * 100)}%`;
if (text) text.textContent = phase === 'extracting' ? t('localModels.extracting') : `${t('localModels.downloading')} ${Math.round(progress * 100)}%`;
});
try {
await localAI.downloadBinary();
unsub();
progressBar.classList.add('hidden');
await refresh();
} catch (err) {
unsub();
const text = bar.querySelector('#binary-status-text');
if (text) text.textContent = `Error: ${err.message}`;
btn.disabled = false;
btn.textContent = t('common.retry');
}
};
if (isLocalAIAvailable()) refresh();
return bar;
}
// ─── Auxiliary file row (text encoder / VAE for Z-Image) ─────────────────────
function AuxRow(label, auxKey, initStatus, onStateChange) {
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-white/3 border border-white/5';
const isReady = initStatus === 'downloaded';
row.innerHTML = `
<div class="flex items-center gap-2 min-w-0">
${isReady
? `<span class="text-green-400 shrink-0">${CheckIcon}</span>`
: `<span class="text-yellow-400 shrink-0">!</span>`}
<span class="text-[11px] text-white truncate">${label}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
${isReady
? `<span class="text-[10px] text-green-400">${t('localModels.ready')}</span>`
: `<button class="aux-dl-btn flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">${DownloadIcon} ${t('localModels.get')}</button>`}
</div>
<div class="aux-progress hidden w-full col-span-2 mt-1">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div class="aux-fill h-full bg-primary transition-all" style="width:0%"></div>
</div>
<span class="aux-text text-[10px] text-muted block mt-0.5">${t('localModels.downloading')}</span>
</div>
`;
const btn = row.querySelector('.aux-dl-btn');
if (btn) {
btn.onclick = async () => {
btn.disabled = true;
btn.innerHTML = `<span class="animate-spin">◌</span>`;
const progWrap = row.querySelector('.aux-progress');
const progFill = row.querySelector('.aux-fill');
const progText = row.querySelector('.aux-text');
progWrap.classList.remove('hidden');
const auxId = auxKey === 'llm' ? '__llm__' : '__vae__';
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
if (id !== auxId) return;
progFill.style.width = `${Math.round(progress * 100)}%`;
progText.textContent = phase === 'done' ? t('localModels.complete') : `${t('localModels.downloading')} ${Math.round(progress * 100)}%`;
});
try {
await localAI.downloadAuxiliary(auxKey);
unsub();
if (onStateChange) onStateChange();
} catch (err) {
unsub();
progText.textContent = `Error: ${err.message}`;
btn.disabled = false;
btn.innerHTML = `${DownloadIcon} ${t('common.retry')}`;
}
};
}
return row;
}
// ─── Wan2GP Server Config ────────────────────────────────────────────────────
function Wan2gpConfigBar(onChange) {
const wrap = document.createElement('div');
wrap.className = 'flex flex-col gap-3 p-3 rounded-xl bg-white/3 border border-white/5';
wrap.innerHTML = `
<div class="flex flex-col gap-0.5">
<span class="text-xs font-bold text-white">Wan2GP server (optional)</span>
<span class="text-[11px] text-muted leading-relaxed">
Run <a href="https://github.com/deepbeepmeep/Wan2GP" target="_blank" class="text-primary hover:underline">Wan2GP</a>
on a CUDA box (<code class="text-primary/80">python wgp.py --listen --server-name 0.0.0.0</code>) to unlock video models from this UI.
</span>
</div>
<div class="flex items-center gap-2">
<input id="wan2gp-url" type="text" placeholder="http://127.0.0.1:7860"
class="flex-1 bg-white/5 border border-white/5 focus:border-primary/40 rounded-lg px-3 py-1.5 text-xs text-white placeholder-white/30 focus:outline-none"/>
<button id="wan2gp-test" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">Test</button>
<button id="wan2gp-save" class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">Save</button>
</div>
<div id="wan2gp-status" class="text-[11px] text-muted">${t('localModels.notConfigured')}</div>
`;
const input = wrap.querySelector('#wan2gp-url');
const testBtn = wrap.querySelector('#wan2gp-test');
const saveBtn = wrap.querySelector('#wan2gp-save');
const statusEl = wrap.querySelector('#wan2gp-status');
const setStatus = (text, kind = 'muted') => {
const colorMap = { muted: 'text-muted', ok: 'text-green-400', warn: 'text-yellow-400', err: 'text-red-400' };
statusEl.className = `text-[11px] ${colorMap[kind] || colorMap.muted}`;
statusEl.textContent = text;
};
(async () => {
const cfg = await localAI.getWan2gpConfig();
if (cfg.url) {
input.value = cfg.url;
const r = await localAI.probeWan2gp(cfg.url);
setStatus(r.ok ? `Connected · Gradio ${r.version}` : `Saved URL not reachable: ${r.error}`, r.ok ? 'ok' : 'warn');
} else {
setStatus(t('localModels.notConfiguredNote'), 'muted');
}
})();
testBtn.onclick = async () => {
const url = input.value.trim();
if (!url) { setStatus('Enter a URL first', 'warn'); return; }
setStatus(t('localModels.probing'), 'muted');
testBtn.disabled = true;
try {
const r = await localAI.probeWan2gp(url);
setStatus(r.ok ? `Reachable · Gradio ${r.version}` : `Unreachable: ${r.error}`, r.ok ? 'ok' : 'err');
} finally { testBtn.disabled = false; }
};
saveBtn.onclick = async () => {
const url = input.value.trim();
saveBtn.disabled = true;
try {
await localAI.setWan2gpUrl(url);
const r = url ? await localAI.probeWan2gp(url) : { ok: false, error: 'cleared' };
setStatus(r.ok ? `Saved · Connected to Gradio ${r.version}` : (url ? `Saved, not reachable: ${r.error}` : 'Cleared'), r.ok ? 'ok' : 'warn');
onChange?.();
} finally { saveBtn.disabled = false; }
};
return wrap;
}
// ─── Model Card ───────────────────────────────────────────────────────────────
function Wan2gpModelCard(model) {
const card = document.createElement('div');
card.className = 'flex items-start justify-between gap-3 p-4 rounded-xl border border-white/5 bg-white/3';
const ready = !!model.ready;
card.innerHTML = `
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-bold text-white truncate">${model.name}</span>
${ready ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
</div>
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
<div class="flex items-center gap-1.5 flex-wrap mt-1">
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold ${model.type === 'video' ? 'bg-purple-500/15 text-purple-300' : 'bg-primary/10 text-primary'}">${model.type.toUpperCase()}</span>
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">via Wan2GP</span>
${(model.tags || []).filter(t => !['featured', 'remote'].includes(t)).map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
</div>
</div>
<div class="shrink-0">
<span class="text-[10px] font-bold ${ready ? 'text-green-400' : 'text-yellow-400'}">${ready ? t('localModels.available') : t('localModels.offline')}</span>
</div>
`;
return card;
}
function ModelCard(model, onStateChange) {
if (model.provider === 'wan2gp') return Wan2gpModelCard(model);
const card = document.createElement('div');
card.className = 'flex flex-col gap-3 p-4 rounded-xl border border-white/5 bg-white/3 hover:border-white/10 transition-all';
const isDownloaded = model.state === 'downloaded';
const auxStatus = model.auxiliaryStatus || {};
const auxReady = !model.requiresAuxiliary || (auxStatus.llm === 'downloaded' && auxStatus.vae === 'downloaded');
const fullyReady = isDownloaded && auxReady;
card.innerHTML = `
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-bold text-white truncate">${model.name}</span>
${model.featured ? `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-black bg-primary/20 text-primary border border-primary/30">${t('localModels.featured')}</span>` : ''}
${fullyReady ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
</div>
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
<div class="flex items-center gap-1.5 flex-wrap mt-1">
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-primary/10 text-primary">${model.type.toUpperCase()}</span>
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${fmtGB(model.sizeGB)}</span>
${(model.tags || []).filter(t => t !== 'featured').map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
${isDownloaded
? `<button class="delete-btn p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-all">${TrashIcon}</button>`
: `<button class="download-btn flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">${DownloadIcon} ${t('localModels.download')}</button>`
}
</div>
</div>
<div class="progress-wrap hidden">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div class="progress-fill h-full bg-primary transition-all" style="width:0%"></div>
</div>
<span class="progress-text text-[10px] text-muted mt-1 block">${t('localModels.preparing')}</span>
</div>
${model.requiresAuxiliary ? `<div class="aux-section flex flex-col gap-1.5 pt-1 border-t border-white/5"></div>` : ''}
`;
// Auxiliary files section for Z-Image
if (model.requiresAuxiliary) {
const auxSection = card.querySelector('.aux-section');
auxSection.appendChild(document.createElement('span')).className = 'text-[10px] text-muted uppercase tracking-wider font-bold';
auxSection.querySelector('span').textContent = t('localModels.requiredComponents');
auxSection.appendChild(AuxRow('Qwen3-4B Text Encoder (2.4 GB)', 'llm', auxStatus.llm, onStateChange));
auxSection.appendChild(AuxRow('FLUX VAE (335 MB)', 'vae', auxStatus.vae, onStateChange));
}
const progressWrap = card.querySelector('.progress-wrap');
const progressFill = card.querySelector('.progress-fill');
const progressText = card.querySelector('.progress-text');
const downloadBtn = card.querySelector('.download-btn');
if (downloadBtn) {
downloadBtn.onclick = async () => {
downloadBtn.disabled = true;
downloadBtn.innerHTML = `<span class="animate-spin">◌</span> ${t('localModels.starting')}`;
progressWrap.classList.remove('hidden');
const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => {
if (id !== model.id) return;
progressFill.style.width = `${Math.round(progress * 100)}%`;
progressText.textContent = phase === 'done' ? t('localModels.complete') : `${t('localModels.downloading')} ${Math.round(progress * 100)}%`;
});
try {
await localAI.downloadModel(model.id);
unsub();
if (onStateChange) onStateChange();
} catch (err) {
unsub();
progressText.textContent = `Error: ${err.message}`;
downloadBtn.disabled = false;
downloadBtn.innerHTML = `${DownloadIcon} ${t('common.retry')}`;
}
};
}
const deleteBtn = card.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.onclick = async () => {
if (!confirm(tf('localModels.deleteConfirm', model.name))) return;
await localAI.deleteModel(model.id);
if (onStateChange) onStateChange();
};
}
return card;
}
// ─── Main component ───────────────────────────────────────────────────────────
export function LocalModelManager() {
const root = document.createElement('div');
root.className = 'flex flex-col gap-5';
if (!isLocalAIAvailable()) {
root.innerHTML = `
<div class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm font-bold text-white">${t('localModels.title')}</p>
<p class="text-xs text-muted max-w-xs">${t('localModels.webOnly')}</p>
</div>
`;
return root;
}
// ── Section: engine status
const engineSection = document.createElement('div');
engineSection.className = 'flex flex-col gap-2';
engineSection.innerHTML = `<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">${t('localModels.inferenceEngine')}</h3>`;
let binaryReady = false;
const binaryBar = BinaryStatusBar((ready) => { binaryReady = ready; });
engineSection.appendChild(binaryBar);
const wan2gpBar = Wan2gpConfigBar(() => renderModels());
engineSection.appendChild(wan2gpBar);
root.appendChild(engineSection);
// ── Section: models
const modelsSection = document.createElement('div');
modelsSection.className = 'flex flex-col gap-3';
modelsSection.innerHTML = `
<div class="flex items-center justify-between gap-3">
<h3 class="text-xs font-bold text-secondary uppercase tracking-wider shrink-0">${t('localModels.title')}</h3>
<span id="local-model-storage" class="min-w-0 truncate text-right text-[10px] text-muted">${t('localModels.checkingStorage')}</span>
</div>
<div id="local-model-list" class="flex flex-col gap-3"></div>
`;
root.appendChild(modelsSection);
const listEl = modelsSection.querySelector('#local-model-list');
const storageEl = modelsSection.querySelector('#local-model-storage');
const refreshStorageInfo = async () => {
try {
const status = await localAI.getBinaryStatus();
const storagePath = status.modelsDir || status.dataDir;
storageEl.textContent = storagePath ? `${t('localModels.storedIn')} ${storagePath}` : t('localModels.storedDefault');
if (storagePath && status.envVar) {
storageEl.title = `Set ${status.envVar} before launch to change this location`;
}
} catch (_) {
storageEl.textContent = t('localModels.storedDefault');
}
};
const renderModels = async () => {
listEl.innerHTML = `<div class="text-xs text-muted text-center py-4">${t('localModels.loading')}</div>`;
try {
const models = await localAI.listModels();
listEl.innerHTML = '';
models.forEach(m => {
listEl.appendChild(ModelCard(m, renderModels));
});
} catch (err) {
listEl.innerHTML = `<div class="text-xs text-red-400 text-center py-4">${t('localModels.errorLoading')}${err.message}</div>`;
}
};
refreshStorageInfo();
renderModels();
return root;
}
+201
View File
@@ -0,0 +1,201 @@
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';
const inner = document.createElement('div');
inner.className = 'max-w-5xl mx-auto px-6 py-12 flex flex-col gap-12';
container.appendChild(inner);
// Hero
const hero = document.createElement('section');
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">
${t('mcp.tagline')}
</div>
<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">
${t('mcp.subtitle')}
</p>
`;
inner.appendChild(hero);
// Quick start
const quick = document.createElement('section');
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">${t('mcp.quickStart')}</span>
<div class="flex-1 h-px bg-white/5"></div>
</div>
<div class="grid md:grid-cols-3 gap-4">
${quickStep('1', 'Install the CLI', 'npm install -g muapi-cli')}
${quickStep('2', 'Sign in', 'muapi auth login')}
${quickStep('3', 'Generate from chat', 'npx skills add SamurAIGPT/Generative-Media-Skills')}
</div>
`;
inner.appendChild(quick);
// Feature cards
const cards = document.createElement('section');
cards.className = 'grid md:grid-cols-3 gap-4';
cards.appendChild(featureCard({
tag: 'CLI',
title: 'muapi-cli',
body: 'Generate images, videos, and audio from the terminal across 14+ AI models. Dual interface — colored human output plus JSON for agents (--output-json, --jq filtering). Async workflows, file uploads, credit tracking.',
code: 'npm install -g muapi-cli\nmuapi image generate "a cyberpunk city" \\\n --model flux-dev',
link: 'https://github.com/SamurAIGPT/muapi-cli',
linkLabel: 'View muapi-cli on GitHub',
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
}));
cards.appendChild(featureCard({
tag: 'MCP',
title: 'muapi-mcp-server',
body: 'Connect Claude, Cursor, Windsurf, and any MCP-compatible assistant to 100+ generative models. Hosted endpoint — no install. 19 structured tools with input/output schemas, async polling, and account management.',
code: 'claude mcp add --transport http muapi \\\n https://api.muapi.ai/mcp \\\n --header "Authorization: Bearer YOUR_KEY"',
link: 'https://github.com/SamurAIGPT/muapi-mcp-server',
linkLabel: 'View muapi-mcp-server on GitHub',
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24"/></svg>`,
}));
cards.appendChild(featureCard({
tag: 'Skills',
title: 'Generative Media Skills',
body: 'Multimodal toolkit for Claude Code, Cursor, and Gemini CLI. Cinema Director, Nano-Banana, UI Designer, Logo Creator, Seedance 2, AI Clipping, and YouTube Shorts presets. Agent-native with JSON outputs and semantic exit codes.',
code: 'npx skills add SamurAIGPT/Generative-Media-Skills --all',
link: 'https://github.com/SamurAIGPT/Generative-Media-Skills',
linkLabel: 'View Generative-Media-Skills on GitHub',
icon: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.39 4.84L20 8l-4 3.9.94 5.5L12 14.77 7.06 17.4 8 11.9 4 8l5.61-1.16L12 2z"/></svg>`,
}));
inner.appendChild(cards);
// Usage examples
const examples = document.createElement('section');
examples.className = 'flex flex-col gap-4';
examples.innerHTML = `
<div class="flex items-center gap-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Examples</span>
<div class="flex-1 h-px bg-white/5"></div>
</div>
<div class="grid md:grid-cols-2 gap-4">
${exampleBlock('Image generation', 'muapi image generate "a serene mountain lake at sunrise" \\\n --model flux-dev --download ./outputs')}
${exampleBlock('Text-to-video', 'muapi video generate "a dog running on a beach" \\\n --model kling-master')}
${exampleBlock('Audio creation', 'muapi audio create "upbeat lo-fi hip hop for studying"')}
${exampleBlock('Run a skill', 'bash library/visual/nano-banana/scripts/\\\n generate-nano-art.sh --file image.jpg --view')}
</div>
`;
inner.appendChild(examples);
// NEW FEATURE: Interactive Command Generator Playground
const playground = document.createElement('section');
playground.className = 'glass-panel rounded-2xl p-6 md:p-8 flex flex-col gap-6';
playground.innerHTML = `
<div class="flex items-center gap-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-secondary">Interactive Builder</span>
<div class="flex-1 h-px bg-white/5"></div>
</div>
<div class="flex flex-col md:flex-row gap-4 items-end">
<div class="flex flex-col gap-1.5 flex-1 w-full">
<label class="text-[11px] font-bold tracking-wider uppercase text-secondary">Select Target Model</label>
<select id="modelSelector" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30">
<option value="image generate 'a retro-futuristic synthwave car' --model flux-dev">Flux Dev (Image)</option>
<option value="image generate 'oil painting of an astronaut' --model midjourney">Midjourney (Image)</option>
<option value="video generate 'cinematic drone shot of an ancient castle' --model kling-master">Kling Master (Video)</option>
<option value="video generate 'cyberpunk neon alleyway rain' --model luma-ray">Luma Ray (Video)</option>
<option value="audio create 'ambient electronic space synth for gaming'">Lyria 3 (Audio)</option>
</select>
</div>
<div class="flex flex-col gap-1.5 flex-1 w-full">
<label class="text-[11px] font-bold tracking-wider uppercase text-secondary">Output Format</label>
<select id="flagSelector" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30">
<option value="">Default (Human-Readable)</option>
<option value=" --output-json">JSON Object (AI Agent Native)</option>
<option value=" --output-json | jq '.data.url'">Filtered URL Stream (JQ)</option>
</select>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-[12px] font-bold text-white/80">Generated Terminal Command</span>
<pre id="interactiveTerminal" class="text-[12px] font-mono text-primary bg-black/60 border border-white/5 rounded-lg px-4 py-3 overflow-x-auto whitespace-pre"></pre>
</div>
`;
inner.appendChild(playground);
// Event listener block to power the dynamic playground execution
const modelSel = playground.querySelector('#modelSelector');
const flagSel = playground.querySelector('#flagSelector');
const termDisp = playground.querySelector('#interactiveTerminal');
const updateCommandDisplay = () => {
termDisp.textContent = `muapi ${modelSel.value}${flagSel.value}`;
};
modelSel.addEventListener('change', updateCommandDisplay);
flagSel.addEventListener('change', updateCommandDisplay);
updateCommandDisplay(); // Run once immediately on build mount
// Footer note
const footer = document.createElement('p');
footer.className = 'text-center text-xs text-secondary opacity-60 pb-4';
footer.textContent = 'Open-source · MIT licensed · Works with Claude, Cursor, Windsurf, and Gemini CLI';
inner.appendChild(footer);
return container;
}
function quickStep(num, title, code) {
return `
<div class="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-white text-black text-xs font-bold flex items-center justify-center">${num}</span>
<span class="text-sm font-bold">${title}</span>
</div>
<code class="text-[12px] font-mono text-primary bg-black/40 rounded-md px-2 py-1.5 break-all">${escapeHtml(code)}</code>
</div>
`;
}
function featureCard({ tag, title, body, code, link, linkLabel, icon }) {
const card = document.createElement('a');
card.href = link;
card.target = '_blank';
card.rel = 'noopener noreferrer';
card.setAttribute('aria-label', linkLabel);
card.className = 'glass-panel rounded-2xl p-6 flex flex-col gap-3 hover:bg-white/[0.04] transition-colors group';
card.innerHTML = `
<div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-white">${icon}</div>
<span class="text-[10px] font-bold uppercase tracking-widest text-secondary">${tag}</span>
</div>
<h3 class="text-lg font-bold">${title}</h3>
<p class="text-[13px] text-secondary leading-relaxed">${body}</p>
<pre class="text-[11px] font-mono text-primary bg-black/40 rounded-md px-3 py-2 overflow-x-auto whitespace-pre">${escapeHtml(code)}</pre>
<div class="flex items-center gap-1 text-[12px] font-bold text-secondary group-hover:text-white transition-colors mt-auto">
<span>View on GitHub</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
</div>
`;
return card;
}
function exampleBlock(title, code) {
return `
<div class="rounded-xl border border-white/5 bg-white/[0.02] p-4 flex flex-col gap-2">
<span class="text-[12px] font-bold text-white/80">${title}</span>
<pre class="text-[11px] font-mono text-primary bg-black/40 rounded-md px-3 py-2 overflow-x-auto whitespace-pre">${escapeHtml(code)}</pre>
</div>
`;
}
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+118
View File
@@ -0,0 +1,118 @@
import { LocalModelManager } from './LocalModelManager.js';
import { isLocalAIAvailable } from '../lib/localInferenceClient.js';
import { t } from '../lib/i18n.js';
export function SettingsModal(onClose) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:100;';
const modal = document.createElement('div');
modal.style.cssText = 'background:var(--bg-card,#111);border-radius:1rem;border:1px solid rgba(255,255,255,0.08);width:min(90vw,36rem);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;';
// ── Header ────────────────────────────────────────────────────────────────
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:1.25rem 1.5rem;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;';
header.innerHTML = `
<h2 style="font-size:1rem;font-weight:800;color:#fff;margin:0;">${t('settings.title')}</h2>
<button id="settings-close-btn" style="color:rgba(255,255,255,0.4);background:none;border:none;cursor:pointer;padding:4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
`;
modal.appendChild(header);
// ── Tabs ──────────────────────────────────────────────────────────────────
const TABS = [
{ id: 'api', label: t('settings.apiKey') },
...(isLocalAIAvailable() ? [{ id: 'local', label: t('settings.localModels') }] : []),
];
let activeTab = 'api';
const tabBar = document.createElement('div');
tabBar.style.cssText = 'display:flex;gap:0.25rem;padding:0.75rem 1.5rem 0;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;';
const tabBtns = {};
TABS.forEach(({ id, label }) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = 'padding:0.4rem 0.75rem;border-radius:0.5rem 0.5rem 0 0;font-size:0.75rem;font-weight:700;border:none;cursor:pointer;transition:all 0.15s;';
btn.onclick = () => switchTab(id);
tabBtns[id] = btn;
tabBar.appendChild(btn);
});
modal.appendChild(tabBar);
// ── Body ──────────────────────────────────────────────────────────────────
const body = document.createElement('div');
body.style.cssText = 'flex:1;overflow-y:auto;padding:1.5rem;';
modal.appendChild(body);
// ── Tab: API Key ──────────────────────────────────────────────────────────
const apiPanel = document.createElement('div');
apiPanel.innerHTML = `
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<div>
<label style="display:block;font-size:0.75rem;color:rgba(255,255,255,0.5);margin-bottom:0.4rem;font-weight:600;">${t('settings.muapiKeyLabel')}</label>
<input id="settings-api-key" type="password"
style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:0.75rem;padding:0.6rem 0.9rem;color:#fff;font-size:0.875rem;outline:none;"
placeholder="${t('settings.keyPlaceholder')}"
value="${localStorage.getItem('muapi_key') || ''}">
</div>
<p style="font-size:0.7rem;color:rgba(255,255,255,0.3);margin:0;">
${t('settings.keyNote')}
</p>
<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.5rem;">
<button id="settings-cancel-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:none;border:1px solid rgba(255,255,255,0.1);color:rgba(255,255,255,0.6);font-size:0.75rem;font-weight:700;cursor:pointer;">${t('common.cancel')}</button>
<button id="settings-save-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:var(--color-primary,#22d3ee);color:#000;font-size:0.75rem;font-weight:700;cursor:pointer;border:none;">${t('common.save')}</button>
</div>
</div>
`;
// ── Tab: Local Models ─────────────────────────────────────────────────────
const localPanel = LocalModelManager();
// ── Tab switching ─────────────────────────────────────────────────────────
const switchTab = (id) => {
activeTab = id;
body.innerHTML = '';
TABS.forEach(({ id: tid }) => {
const btn = tabBtns[tid];
if (tid === id) {
btn.style.background = 'rgba(255,255,255,0.08)';
btn.style.color = '#fff';
} else {
btn.style.background = 'transparent';
btn.style.color = 'rgba(255,255,255,0.4)';
}
});
if (id === 'api') body.appendChild(apiPanel);
if (id === 'local') body.appendChild(localPanel);
};
switchTab('api');
// ── API key save/cancel handlers ──────────────────────────────────────────
const close = () => {
if (document.body.contains(overlay)) document.body.removeChild(overlay);
if (onClose) onClose();
};
apiPanel.querySelector('#settings-cancel-btn').onclick = close;
apiPanel.querySelector('#settings-save-btn').onclick = () => {
const key = apiPanel.querySelector('#settings-api-key').value.trim();
if (key) {
localStorage.setItem('muapi_key', key);
close();
} else {
alert(t('settings.invalidKey'));
}
};
header.querySelector('#settings-close-btn').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.appendChild(modal);
return overlay;
}
+85
View File
@@ -0,0 +1,85 @@
import { t } from '../lib/i18n.js';
export function Sidebar() {
const element = document.createElement('aside');
element.className = 'glass-panel';
element.style.width = '72px';
element.style.height = '100%';
element.style.display = 'flex';
element.style.flexDirection = 'column';
element.style.alignItems = 'center';
element.style.padding = '1.5rem 0';
element.style.zIndex = '50';
element.style.background = 'var(--bg-panel)';
// Logo
const logo = document.createElement('div');
logo.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white"/><path d="M2 17L12 22L22 17" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 12L12 17L22 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
logo.className = 'mb-10 text-primary';
element.appendChild(logo);
const navItems = [
{ id: 'image', icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>', label: t('sidebar.canvas') },
{ id: 'video', icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>', label: t('sidebar.video') },
{ id: 'library', icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>', label: t('sidebar.library') },
];
const bottomItems = [
{ id: 'settings', icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>', label: t('sidebar.settings') }
];
let activeTab = 'image';
const createButton = (item) => {
const container = document.createElement('div');
container.className = 'flex flex-col items-center gap-1 mb-6 cursor-pointer group';
const iconBtn = document.createElement('button');
iconBtn.innerHTML = item.icon;
iconBtn.className = 'w-10 h-10 rounded-xl flex items-center justify-center transition-all bg-transparent text-secondary group-hover:bg-white/5 group-hover:text-white';
const label = document.createElement('span');
label.textContent = item.label;
label.className = 'text-[9px] font-bold uppercase tracking-widest text-secondary group-hover:text-white transition-colors';
if (activeTab === item.id && item.id !== 'settings') {
iconBtn.classList.add('active-nav-btn');
iconBtn.style.color = 'var(--color-primary)';
label.style.color = 'var(--color-primary)';
}
container.onclick = () => {
const event = new CustomEvent('navigate', { detail: { page: item.id } });
window.dispatchEvent(event);
if (item.id !== 'settings') {
activeTab = item.id;
element.querySelectorAll('.active-nav-btn').forEach(btn => {
btn.classList.remove('active-nav-btn');
btn.style.color = 'var(--text-secondary)';
btn.nextSibling.style.color = 'var(--text-secondary)';
});
iconBtn.classList.add('active-nav-btn');
iconBtn.style.color = 'var(--color-primary)';
label.style.color = 'var(--color-primary)';
}
};
container.appendChild(iconBtn);
container.appendChild(label);
return container;
};
const navContainer = document.createElement('div');
navContainer.className = 'flex flex-col flex-1 w-full items-center';
navItems.forEach(item => navContainer.appendChild(createButton(item)));
element.appendChild(navContainer);
const bottomContainer = document.createElement('div');
bottomContainer.className = 'flex flex-col w-full items-center mt-auto';
bottomItems.forEach(item => bottomContainer.appendChild(createButton(item)));
element.appendChild(bottomContainer);
return element;
}
+415
View File
@@ -0,0 +1,415 @@
import { muapi } from '../lib/muapi.js';
import { AuthModal } from './AuthModal.js';
import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '../lib/uploadHistory.js';
/**
* Creates a self-contained upload picker: a trigger button + history panel.
* Supports single-image (maxImages=1) and multi-image (maxImages>1) modes.
*
* @param {object} options
* @param {HTMLElement} options.anchorContainer - The container element the panel is positioned relative to
* @param {function({ url: string, urls: string[], thumbnail: string }): void} options.onSelect
* @param {function(): void} [options.onClear]
* @param {number} [options.maxImages=1] - Maximum number of images selectable
* @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }}
*/
export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1, uploadFn, requireApiKey }) {
// uploadFn(file) → Promise<string url>. Defaults to Muapi-hosted upload.
// requireApiKey() → boolean. Lets the caller suppress the AuthModal when
// the active provider doesn't need a Muapi key (e.g. local Wan2GP).
const doUpload = uploadFn || ((file) => muapi.uploadFile(file));
const needsKey = typeof requireApiKey === 'function' ? requireApiKey : () => true;
let panelOpen = false;
let maxImages = initialMaxImages;
let selectedEntries = []; // [{ url, thumbnail }, ...]
// ── Hidden file input ─────────────────────────────────────────────────────
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.className = 'hidden';
// ── Trigger button ────────────────────────────────────────────────────────
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.title = 'Reference image';
trigger.className = 'w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group';
// State: icon
const iconState = document.createElement('div');
iconState.className = 'flex items-center justify-center w-full h-full';
iconState.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted group-hover:text-primary transition-colors"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`;
// State: spinner
const spinnerState = document.createElement('div');
spinnerState.className = 'hidden items-center justify-center w-full h-full';
spinnerState.innerHTML = `<span class="animate-spin text-primary text-sm">◌</span>`;
// State: thumbnail (first selected image + optional count badge)
const thumbnailState = document.createElement('div');
thumbnailState.className = 'hidden w-full h-full';
const thumbImg = document.createElement('img');
thumbImg.className = 'w-full h-full object-cover';
const countBadge = document.createElement('div');
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5';
countBadge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
thumbnailState.appendChild(thumbImg);
thumbnailState.appendChild(countBadge);
trigger.appendChild(fileInput);
trigger.appendChild(iconState);
trigger.appendChild(spinnerState);
trigger.appendChild(thumbnailState);
// ── Trigger state helpers ─────────────────────────────────────────────────
const showIcon = () => {
iconState.classList.replace('hidden', 'flex');
spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex');
thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex');
trigger.classList.remove('border-primary/60');
trigger.classList.add('border-white/10');
};
const showSpinner = () => {
iconState.classList.add('hidden'); iconState.classList.remove('flex');
spinnerState.classList.replace('hidden', 'flex');
thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex');
};
const updateTrigger = () => {
if (selectedEntries.length === 0) {
showIcon();
trigger.title = maxImages > 1 ? `Add up to ${maxImages} images` : 'Reference image';
return;
}
// Show first image thumbnail
thumbImg.src = selectedEntries[0].thumbnail;
iconState.classList.add('hidden'); iconState.classList.remove('flex');
spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex');
thumbnailState.classList.replace('hidden', 'flex');
trigger.classList.remove('border-white/10');
trigger.classList.add('border-primary/60');
const count = selectedEntries.length;
const canAddMore = maxImages > 1 && count < maxImages;
if (count > 1) {
// Multiple selected — show count
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5';
countBadge.innerHTML = `<span class="text-[9px] font-black text-black leading-none">${count}</span>`;
trigger.title = `${count} of ${maxImages} images selected — click to manage`;
} else if (canAddMore) {
// 1 selected, multi-mode active — show "+" to invite adding more
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-white/80 rounded-full flex items-center justify-center px-0.5 border border-primary/60';
countBadge.innerHTML = `<span class="text-[9px] font-black text-black leading-none">+</span>`;
trigger.title = `1 image selected — click to add more (up to ${maxImages})`;
} else {
// Single mode or at max — show checkmark
countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5';
countBadge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
trigger.title = count > 1 ? `${count} images selected` : 'Reference image';
}
};
// ── Panel ─────────────────────────────────────────────────────────────────
const panel = document.createElement('div');
panel.className = 'absolute z-50 opacity-0 pointer-events-none scale-95 origin-bottom-left glass rounded-3xl p-3 shadow-4xl border border-white/10 w-72 transition-all';
const openPanel = () => {
renderPanel();
panel.classList.remove('opacity-0', 'pointer-events-none', 'scale-95');
panel.classList.add('opacity-100', 'pointer-events-auto', 'scale-100');
const btnRect = trigger.getBoundingClientRect();
const containerRect = anchorContainer.getBoundingClientRect();
panel.style.left = `${btnRect.left - containerRect.left}px`;
panel.style.bottom = `${containerRect.bottom - btnRect.top + 8}px`;
panelOpen = true;
};
const closePanel = () => {
panel.classList.add('opacity-0', 'pointer-events-none', 'scale-95');
panel.classList.remove('opacity-100', 'pointer-events-auto', 'scale-100');
panelOpen = false;
};
const fireOnSelect = () => {
if (selectedEntries.length === 0) return;
const urls = selectedEntries.map(e => e.url);
onSelect({
url: urls[0], // backward-compatible single URL
urls, // full array for multi-image models
thumbnail: selectedEntries[0].thumbnail
});
};
const renderPanel = () => {
panel.innerHTML = '';
const history = getUploadHistory();
const isMulti = maxImages > 1;
// ── Header ──
const header = document.createElement('div');
header.className = 'flex items-center justify-between px-1 pb-3 mb-2 border-b border-white/5';
const headerLeft = document.createElement('div');
headerLeft.className = 'flex flex-col gap-0.5';
headerLeft.innerHTML = `<span class="text-[10px] font-bold text-secondary uppercase tracking-widest">Reference Images</span>`;
if (isMulti) {
const hint = document.createElement('span');
hint.className = 'text-[9px] text-muted';
hint.textContent = `Select up to ${maxImages} images`;
headerLeft.appendChild(hint);
}
header.appendChild(headerLeft);
const headerRight = document.createElement('div');
headerRight.className = 'flex items-center gap-2';
// Done button (multi-select only)
if (isMulti && selectedEntries.length > 0) {
const doneBtn = document.createElement('button');
doneBtn.type = 'button';
doneBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105';
doneBtn.innerHTML = `✓ Done (${selectedEntries.length})`;
doneBtn.onclick = (e) => {
e.stopPropagation();
closePanel();
fireOnSelect();
};
headerRight.appendChild(doneBtn);
}
const uploadNewBtn = document.createElement('button');
uploadNewBtn.type = 'button';
uploadNewBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl text-xs font-bold transition-all border border-primary/20';
const uploadLabel = isMulti ? 'Upload files' : 'Upload new';
uploadNewBtn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> ${uploadLabel}`;
uploadNewBtn.onclick = (e) => { e.stopPropagation(); closePanel(); fileInput.click(); };
headerRight.appendChild(uploadNewBtn);
header.appendChild(headerRight);
panel.appendChild(header);
if (history.length === 0) {
const empty = document.createElement('div');
empty.className = 'py-6 flex flex-col items-center gap-2 opacity-40';
empty.innerHTML = `
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-secondary"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span class="text-xs text-secondary">No uploads yet</span>
`;
panel.appendChild(empty);
return;
}
// ── Grid ──
const grid = document.createElement('div');
grid.className = 'grid grid-cols-3 gap-2 max-h-56 overflow-y-auto custom-scrollbar pr-0.5';
history.forEach(entry => {
const selIdx = selectedEntries.findIndex(e => e.url === entry.uploadedUrl);
const isSelected = selIdx !== -1;
const cell = document.createElement('div');
cell.className = `relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${isSelected ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`;
cell.title = entry.name;
const img = document.createElement('img');
img.src = entry.thumbnail;
img.className = 'w-full h-full object-cover';
// Hover overlay with delete button
const overlay = document.createElement('div');
overlay.className = 'absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1';
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'w-5 h-5 bg-red-500/80 hover:bg-red-500 rounded-md flex items-center justify-center transition-colors';
delBtn.title = 'Remove from history';
delBtn.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
delBtn.onclick = (e) => {
e.stopPropagation();
removeUpload(entry.id);
const idx = selectedEntries.findIndex(e => e.url === entry.uploadedUrl);
if (idx !== -1) {
selectedEntries.splice(idx, 1);
updateTrigger();
if (selectedEntries.length === 0) onClear?.();
}
renderPanel();
};
overlay.appendChild(delBtn);
// Selection badge: order number (multi) or checkmark (single)
if (isSelected) {
const badge = document.createElement('div');
badge.className = 'absolute top-1 left-1 min-w-[20px] h-5 bg-primary rounded-full flex items-center justify-center px-1';
if (isMulti) {
badge.innerHTML = `<span class="text-[10px] font-black text-black">${selIdx + 1}</span>`;
} else {
badge.innerHTML = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`;
}
cell.appendChild(badge);
}
// Not-yet-reachable dim (when at max)
const atMax = isMulti && !isSelected && selectedEntries.length >= maxImages;
if (atMax) {
cell.classList.add('opacity-40');
cell.style.cursor = 'not-allowed';
}
cell.appendChild(img);
cell.appendChild(overlay);
cell.onclick = (e) => {
e.stopPropagation();
if (atMax) return; // can't select more
if (!isMulti) {
// Single-select: select & close immediately
selectedEntries = [{ url: entry.uploadedUrl, thumbnail: entry.thumbnail }];
updateTrigger();
fireOnSelect();
closePanel();
} else {
// Multi-select: toggle
if (isSelected) {
selectedEntries.splice(selIdx, 1);
if (selectedEntries.length === 0) onClear?.();
} else {
selectedEntries.push({ url: entry.uploadedUrl, thumbnail: entry.thumbnail });
}
updateTrigger();
renderPanel(); // re-render to update badges / dim state
}
};
grid.appendChild(cell);
});
panel.appendChild(grid);
// Bottom "Done" bar for multi-select (always visible when items selected)
if (isMulti && selectedEntries.length > 0) {
const bottomBar = document.createElement('div');
bottomBar.className = 'mt-3 pt-3 border-t border-white/5 flex items-center justify-between';
bottomBar.innerHTML = `<span class="text-xs text-secondary">${selectedEntries.length} of ${maxImages} selected</span>`;
const doneBtn2 = document.createElement('button');
doneBtn2.type = 'button';
doneBtn2.className = 'px-4 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105';
doneBtn2.textContent = 'Use Selected';
doneBtn2.onclick = (e) => {
e.stopPropagation();
closePanel();
fireOnSelect();
};
bottomBar.appendChild(doneBtn2);
panel.appendChild(bottomBar);
}
};
// ── Trigger click ─────────────────────────────────────────────────────────
trigger.onclick = (e) => {
e.stopPropagation();
if (panelOpen) closePanel();
else openPanel();
};
// Close panel on outside click
window.addEventListener('click', closePanel);
// ── File upload handler ───────────────────────────────────────────────────
fileInput.onchange = async (e) => {
const files = Array.from(e.target.files);
if (!files.length) return;
if (needsKey()) {
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) {
AuthModal(() => fileInput.click());
return;
}
}
showSpinner();
try {
if (maxImages === 1) {
// Single mode: upload first file only, replace selection
const file = files[0];
const [uploadResult, thumbnail] = await Promise.all([
doUpload(file),
generateThumbnail(file)
]);
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
saveUpload(entry);
selectedEntries = [{ url: uploadedUrl, thumbnail }];
updateTrigger();
fireOnSelect();
} else {
// Multi mode: upload all files (up to remaining slots)
const slots = maxImages - selectedEntries.length;
const toUpload = files.slice(0, Math.max(slots, 1));
// Upload all in parallel
const results = await Promise.all(toUpload.map(async (file) => {
const [uploadResult, thumbnail] = await Promise.all([
doUpload(file),
generateThumbnail(file)
]);
const uploadedUrl = typeof uploadResult === 'string' ? uploadResult : uploadResult?.url;
return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() };
}));
results.forEach(entry => {
saveUpload(entry);
if (selectedEntries.length < maxImages) {
selectedEntries.push({ url: entry.uploadedUrl, thumbnail: entry.thumbnail });
}
});
updateTrigger();
// In multi-mode reopen panel so user can continue selecting / see Done button
openPanel();
}
} catch (err) {
console.error('[UploadPicker] Upload failed:', err);
updateTrigger();
alert(`Image upload failed: ${err.message}`);
}
fileInput.value = '';
};
// ── Public API ────────────────────────────────────────────────────────────
const reset = () => {
selectedEntries = [];
showIcon();
closePanel();
};
const setMaxImages = (n) => {
maxImages = n;
// Enable multi-file selection in file picker when multi-mode
fileInput.multiple = n > 1;
// Trim selection if exceeding new limit
if (selectedEntries.length > n) {
selectedEntries = selectedEntries.slice(0, n);
if (selectedEntries.length === 0) onClear?.();
}
// Always refresh trigger so badge/tooltip reflects new mode
updateTrigger();
};
const getSelectedUrls = () => selectedEntries.map(e => e.url);
// Programmatically select an image (e.g. for demo mode) without uploading
const setImage = (url, thumbnail) => {
selectedEntries = [{ url, thumbnail: thumbnail || url }];
updateTrigger();
fireOnSelect();
};
return { trigger, panel, reset, setMaxImages, getSelectedUrls, setImage };
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { t } from '../lib/i18n.js';
export function WorkflowStudio() {
const container = document.createElement('div');
container.className = 'w-full h-full flex flex-col items-center justify-center bg-app-bg text-white gap-4';
const icon = document.createElement('div');
icon.innerHTML = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
<path d="M6.5 10v4M17.5 10v4M10 6.5h4M10 17.5h4"/>
</svg>`;
const title = document.createElement('p');
title.textContent = t('workflows.title');
title.className = 'text-lg font-bold opacity-60';
const sub = document.createElement('p');
sub.textContent = t('workflows.webOnly');
sub.className = 'text-sm opacity-40';
container.appendChild(icon);
container.appendChild(title);
container.appendChild(sub);
return container;
}
+9
View File
@@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

+470
View File
@@ -0,0 +1,470 @@
const LANG_KEY = 'og_lang';
/** 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';
}
/** 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);
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 = {
en: {
// Navigation
'nav.image': 'Image',
'nav.video': 'Video',
'nav.lipsync': 'Lip Sync',
'nav.cinema': 'Cinema Studio',
'nav.workflows': 'Workflows',
'nav.agents': 'Agents',
'nav.mcpcli': 'MCP & CLI',
'nav.settings': 'Settings',
// Sidebar
'sidebar.canvas': 'Canvas',
'sidebar.video': 'Video',
'sidebar.library': 'Library',
'sidebar.settings': 'Settings',
// Common
'common.generate': 'Generate ✨',
'common.generating': 'Generating...',
'common.download': '↓ Download',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.history': 'History',
'common.advanced': 'Advanced',
'common.less': 'Less',
'common.tools': 'Tools',
'common.copy': 'Copy',
'common.copied': 'Copied!',
'common.searchModels': 'Search models...',
'common.retry': 'Retry',
'common.loading': 'Loading...',
'common.noResults': 'No local models match',
'common.regenerate': '↻ Regenerate',
'common.newItem': '+ New',
'common.useInGenerator': 'Use in Generator',
'common.randomize': 'Randomize',
// Settings Modal
'settings.title': 'Settings',
'settings.apiKey': 'API Key',
'settings.localModels': 'Local Models',
'settings.muapiKeyLabel': 'Muapi API Key',
'settings.keyPlaceholder': 'Enter your Muapi API key...',
'settings.keyNote': 'Your API key is stored locally and never sent anywhere except api.muapi.ai.',
'settings.invalidKey': 'Please enter a valid API key.',
// Auth Modal
'auth.title': 'Muapi API Key Required',
'auth.subtitle': 'Create a Muapi access key, then paste the key value here to start creating high-aesthetic images.',
'auth.keyLabel': 'Muapi Access Key',
'auth.keyPlaceholder': 'Paste your access key value...',
'auth.keyNote': 'Do not enter the key name or label; paste the generated key value from Muapi.',
'auth.initBtn': 'Initialize Studio',
'auth.createKey': 'Create or copy a Muapi access key →',
// Image Studio
'image.title': 'Image Studio',
'image.subtitle': 'Transform images with AI — upscale, stylize, animate and more',
'image.placeholder': 'Describe the image you want to create',
'image.placeholderTransform': 'Describe how to transform this image (optional)',
'image.generateTooltip': 'Generate AI image from prompt',
'image.modelTooltip': 'Select AI generation model',
'image.arTooltip': 'Change aspect ratio',
'image.qualityTooltip': 'Set output quality',
'image.advancedTooltip': 'Show advanced options',
'image.toolsTooltip': 'Quick starters & prompt enhancer',
'image.local': '⚡ Local',
'image.api': '☁ API',
'image.generatingLocally': 'Generating locally...',
'image.quickTools': 'Quick Tools',
'image.quickStarters': 'Quick Starters',
'image.promptEnhancer': 'Prompt Enhancer',
'image.basePromptPlaceholder': 'Enter base prompt...',
'image.enhancementTags': 'Enhancement Tags',
'image.enhancedPrompt': 'Enhanced Prompt',
'image.enhancedPlaceholder': 'Your enhanced prompt will appear here...',
'image.advancedOptions': 'Advanced Options',
'image.stylePreset': 'Style Preset',
'image.negPromptLabel': 'Negative Prompt',
'image.negPromptPlaceholder': 'What to exclude from the image (e.g., blurry, distorted, watermark)',
'image.guidanceScale': 'Guidance Scale',
'image.steps': 'Steps',
'image.seed': 'Seed',
'image.seedPlaceholder': '-1 for random',
'image.batchCount': 'Batch Count',
'image.width': 'Width',
'image.height': 'Height',
'image.widthPlaceholder': 'Auto',
'image.heightPlaceholder': 'Auto',
'image.refStrength': 'Reference Strength',
'image.refStrengthNote': 'How much to preserve the reference image characteristics',
'image.lora': 'LoRA Model (Optional)',
'image.loraPlaceholder': 'e.g., civitai:1642876@1864626',
'image.loraWeight': 'LoRA Weight:',
'image.loraNote': 'Enter a LoRA model ID from Civitai (format: civitai:id@version)',
// Video Studio
'video.title': 'Video Studio',
'video.subtitle': 'Animate images into stunning AI videos with motion effects',
'video.placeholder': 'Describe the video you want to create',
'video.generateTooltip': 'Generate AI video',
'video.history': 'History',
'video.regenerate': '↻ Regenerate',
'video.download': '↓ Download',
'video.extend': '↗ Extend',
'video.new': '+ New',
'video.videoTools': 'Video Tools',
// Lip Sync Studio
'lipsync.title': 'Lip Sync',
'lipsync.subtitle': 'Animate portraits or sync lips to audio with AI',
'lipsync.input': 'Input:',
'lipsync.portraitImage': '🖼 Portrait Image',
'lipsync.video': '🎬 Video',
'lipsync.noImage': 'No image',
'lipsync.noVideo': 'No video',
'lipsync.noAudio': 'No audio',
'lipsync.imageReady': '✓ Image ready',
'lipsync.videoReady': '✓ Video ready',
'lipsync.promptPlaceholder': 'Optional: describe the talking style or motion...',
'lipsync.regenerate': '↻ Regenerate',
'lipsync.download': '↓ Download',
'lipsync.new': '+ New',
'lipsync.history': 'History',
'lipsync.noAudioAlert': 'Please upload an audio file first.',
'lipsync.noImageAlert': 'Please upload a portrait image first.',
'lipsync.noVideoAlert': 'Please upload a source video first.',
// Cinema Studio
'cinema.tagline': 'Cinema Studio 2.0',
'cinema.headline': 'What would you shoot<br>with infinite budget?',
'cinema.placeholder': 'Describe your scene - use @ to add characters & props',
'cinema.builderTooltip': 'Quick camera builder',
'cinema.cameraSettings': 'Open camera settings',
'cinema.generateBtn': 'GENERATE ✨',
'cinema.shooting': 'SHOOTING...',
'cinema.history': 'History',
'cinema.load': 'Load',
'cinema.regenerate': '↻ Regenerate',
'cinema.download': '↓ Download',
'cinema.newShot': '+ New Shot',
'cinema.cameraBuilder': 'Camera Builder',
'cinema.camera': 'Camera',
'cinema.lens': 'Lens',
'cinema.focal': 'Focal',
'cinema.aperture': 'Aperture',
'cinema.preview': 'Preview',
'cinema.useSetup': 'Use This Setup',
'cinema.selectSettings': 'Select camera settings to see preview...',
'cinema.generationFailed': 'Generation Failed: ',
// Agent Studio
'agents.title': 'Agent Studio',
'agents.webOnly': 'Available in the web app at open-generative-ai.com',
// Workflow Studio
'workflows.title': 'Workflow Studio',
'workflows.webOnly': 'Available in the web app at open-generative-ai.com',
// Local Model Manager
'localModels.title': 'Local Models',
'localModels.webOnly': 'Local model inference is only available in the desktop app (Electron build). Use npm run electron:build to build.',
'localModels.inferenceEngine': 'Inference Engine',
'localModels.checking': 'Checking...',
'localModels.installed': 'Installed and ready',
'localModels.notInstalled': 'Not installed — required for local generation',
'localModels.installEngine': 'Install Engine',
'localModels.downloading': 'Downloading...',
'localModels.extracting': 'Extracting...',
'localModels.storedIn': 'Stored in',
'localModels.storedDefault': 'Stored in your app data folder',
'localModels.checkingStorage': 'Checking storage...',
'localModels.loading': 'Loading...',
'localModels.featured': '⚡ Featured',
'localModels.download': 'Download',
'localModels.requiredComponents': 'Required components',
'localModels.ready': 'Ready',
'localModels.available': 'Available',
'localModels.offline': 'Unavailable',
'localModels.starting': 'Starting...',
'localModels.complete': 'Complete!',
'localModels.preparing': 'Preparing...',
'localModels.get': 'Get',
'localModels.notConfigured': 'Not configured',
'localModels.notConfiguredNote': 'Not configured (Wan2GP models will appear offline)',
'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
'nav.image': '图像',
'nav.video': '视频',
'nav.lipsync': '唇语同步',
'nav.cinema': '电影工作室',
'nav.workflows': '工作流',
'nav.agents': '智能体',
'nav.mcpcli': 'MCP & CLI',
'nav.settings': '设置',
// Sidebar
'sidebar.canvas': '画布',
'sidebar.video': '视频',
'sidebar.library': '素材库',
'sidebar.settings': '设置',
// Common
'common.generate': '生成 ✨',
'common.generating': '生成中...',
'common.download': '↓ 下载',
'common.cancel': '取消',
'common.save': '保存',
'common.history': '历史记录',
'common.advanced': '高级',
'common.less': '收起',
'common.tools': '工具',
'common.copy': '复制',
'common.copied': '已复制!',
'common.searchModels': '搜索模型...',
'common.retry': '重试',
'common.loading': '加载中...',
'common.noResults': '未找到本地模型',
'common.regenerate': '↻ 重新生成',
'common.newItem': '+ 新建',
'common.useInGenerator': '用于生成器',
'common.randomize': '随机',
// Settings Modal
'settings.title': '设置',
'settings.apiKey': 'API 密钥',
'settings.localModels': '本地模型',
'settings.muapiKeyLabel': 'Muapi API 密钥',
'settings.keyPlaceholder': '输入您的 Muapi API 密钥...',
'settings.keyNote': '您的 API 密钥仅存储在本地,除 api.muapi.ai 外不会发送到任何地方。',
'settings.invalidKey': '请输入有效的 API 密钥。',
// Auth Modal
'auth.title': '需要 Muapi API 密钥',
'auth.subtitle': '创建一个 Muapi 访问密钥,然后将密钥值粘贴到这里开始创建高质量图像。',
'auth.keyLabel': 'Muapi 访问密钥',
'auth.keyPlaceholder': '粘贴您的访问密钥值...',
'auth.keyNote': '请不要输入密钥名称或标签;粘贴从 Muapi 生成的密钥值。',
'auth.initBtn': '初始化工作室',
'auth.createKey': '创建或复制 Muapi 访问密钥 →',
// Image Studio
'image.title': '图像工作室',
'image.subtitle': '用 AI 转换图像 — 超分辨率、风格化、动画等更多功能',
'image.placeholder': '描述您想创建的图像',
'image.placeholderTransform': '描述您想如何转换此图像(可选)',
'image.generateTooltip': '根据提示词生成 AI 图像',
'image.modelTooltip': '选择 AI 生成模型',
'image.arTooltip': '更改宽高比',
'image.qualityTooltip': '设置输出质量',
'image.advancedTooltip': '显示高级选项',
'image.toolsTooltip': '快速启动器与提示词增强器',
'image.local': '⚡ 本地',
'image.api': '☁ API',
'image.generatingLocally': '本地生成中...',
'image.quickTools': '快速工具',
'image.quickStarters': '快速启动',
'image.promptEnhancer': '提示词增强器',
'image.basePromptPlaceholder': '输入基础提示词...',
'image.enhancementTags': '增强标签',
'image.enhancedPrompt': '增强后的提示词',
'image.enhancedPlaceholder': '增强后的提示词将显示在这里...',
'image.advancedOptions': '高级选项',
'image.stylePreset': '风格预设',
'image.negPromptLabel': '反向提示词',
'image.negPromptPlaceholder': '图像中要排除的内容(如:模糊、失真、水印)',
'image.guidanceScale': '引导系数',
'image.steps': '步数',
'image.seed': '随机种子',
'image.seedPlaceholder': '-1 表示随机',
'image.batchCount': '批量数量',
'image.width': '宽度',
'image.height': '高度',
'image.widthPlaceholder': '自动',
'image.heightPlaceholder': '自动',
'image.refStrength': '参考强度',
'image.refStrengthNote': '保留参考图像特征的程度',
'image.lora': 'LoRA 模型(可选)',
'image.loraPlaceholder': '例如:civitai:1642876@1864626',
'image.loraWeight': 'LoRA 权重:',
'image.loraNote': '输入来自 Civitai 的 LoRA 模型 ID(格式:civitai:id@version',
// Video Studio
'video.title': '视频工作室',
'video.subtitle': '用 AI 将图像动态化为精彩视频,配合运动效果',
'video.placeholder': '描述您想创建的视频',
'video.generateTooltip': '生成 AI 视频',
'video.history': '历史记录',
'video.regenerate': '↻ 重新生成',
'video.download': '↓ 下载',
'video.extend': '↗ 延伸',
'video.new': '+ 新建',
'video.videoTools': '视频工具',
// Lip Sync Studio
'lipsync.title': '唇语同步',
'lipsync.subtitle': '用 AI 为人像制作动画或将音频与唇语同步',
'lipsync.input': '输入:',
'lipsync.portraitImage': '🖼 人像图',
'lipsync.video': '🎬 视频',
'lipsync.noImage': '无图像',
'lipsync.noVideo': '无视频',
'lipsync.noAudio': '无音频',
'lipsync.imageReady': '✓ 图像已就绪',
'lipsync.videoReady': '✓ 视频已就绪',
'lipsync.promptPlaceholder': '可选:描述说话风格或动作...',
'lipsync.regenerate': '↻ 重新生成',
'lipsync.download': '↓ 下载',
'lipsync.new': '+ 新建',
'lipsync.history': '历史记录',
'lipsync.noAudioAlert': '请先上传音频文件。',
'lipsync.noImageAlert': '请先上传人像图片。',
'lipsync.noVideoAlert': '请先上传源视频。',
// Cinema Studio
'cinema.tagline': '电影工作室 2.0',
'cinema.headline': '如果预算无限,<br>你会拍什么?',
'cinema.placeholder': '描述您的场景 - 使用 @ 添加角色和道具',
'cinema.builderTooltip': '快速摄像机设置',
'cinema.cameraSettings': '打开摄像机设置',
'cinema.generateBtn': '生成 ✨',
'cinema.shooting': '拍摄中...',
'cinema.history': '历史记录',
'cinema.load': '加载',
'cinema.regenerate': '↻ 重新生成',
'cinema.download': '↓ 下载',
'cinema.newShot': '+ 新镜头',
'cinema.cameraBuilder': '摄像机设置',
'cinema.camera': '摄像机',
'cinema.lens': '镜头',
'cinema.focal': '焦距',
'cinema.aperture': '光圈',
'cinema.preview': '预览',
'cinema.useSetup': '使用此设置',
'cinema.selectSettings': '选择摄像机设置以查看预览...',
'cinema.generationFailed': '生成失败:',
// Agent Studio
'agents.title': '智能体工作室',
'agents.webOnly': '在网页应用 open-generative-ai.com 上可用',
// Workflow Studio
'workflows.title': '工作流工作室',
'workflows.webOnly': '在网页应用 open-generative-ai.com 上可用',
// Local Model Manager
'localModels.title': '本地模型',
'localModels.webOnly': '本地模型推理仅在桌面应用(Electron 构建版)中可用。使用 npm run electron:build 进行构建。',
'localModels.inferenceEngine': '推理引擎',
'localModels.checking': '检查中...',
'localModels.installed': '已安装,可以使用',
'localModels.notInstalled': '未安装 — 本地生成所必需',
'localModels.installEngine': '安装引擎',
'localModels.downloading': '下载中...',
'localModels.extracting': '解压中...',
'localModels.storedIn': '存储于',
'localModels.storedDefault': '存储在应用数据文件夹中',
'localModels.checkingStorage': '检查存储...',
'localModels.loading': '加载中...',
'localModels.featured': '⚡ 推荐',
'localModels.download': '下载',
'localModels.requiredComponents': '所需组件',
'localModels.ready': '已就绪',
'localModels.available': '可用',
'localModels.offline': '不可用',
'localModels.starting': '启动中...',
'localModels.complete': '完成!',
'localModels.preparing': '准备中...',
'localModels.get': '获取',
'localModels.notConfigured': '未配置',
'localModels.notConfiguredNote': '未配置(Wan2GP 模型将显示为离线)',
'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 = 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 = dictFor(lang);
const val = dict[key] !== undefined ? dict[key] : (translations.en[key] !== undefined ? translations.en[key] : key);
return typeof val === 'function' ? val(...args) : val;
}
+107
View File
@@ -0,0 +1,107 @@
// Frontend client for local inference — wraps window.localAI (Electron IPC).
// Two providers live behind the same surface:
// - sd.cpp: bundled engine, downloads weights to disk, runs locally
// - wan2gp: user-run Gradio server, generation is remote HTTP
// Provider is read off the model entry's `provider` field.
import { getLocalModelById } from './localModels.js';
export const isLocalAIAvailable = () => typeof window !== 'undefined' && !!window.localAI?.isElectron;
class LocalInferenceClient {
// ── sd.cpp APIs ───────────────────────────────────────────────────────
async getBinaryStatus() {
if (!isLocalAIAvailable()) return { exists: false };
return window.localAI.getBinaryStatus();
}
async downloadBinary() {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.downloadBinary();
}
async downloadModel(modelId) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.downloadModel(modelId);
}
async downloadAuxiliary(auxKey) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.downloadAuxiliary(auxKey);
}
async deleteModel(modelId) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.deleteModel(modelId);
}
// ── Wan2GP APIs ───────────────────────────────────────────────────────
async getWan2gpConfig() {
if (!isLocalAIAvailable()) return { url: '' };
return window.localAI.wan2gp.getConfig();
}
async setWan2gpUrl(url) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
return window.localAI.wan2gp.setUrl(url);
}
async probeWan2gp(url) {
if (!isLocalAIAvailable()) return { ok: false, error: 'Not in desktop app' };
return window.localAI.wan2gp.probe(url);
}
// Pushes a File/Blob to the configured Wan2GP server's /upload endpoint
// and returns { url, path }. URL is a previewable HTTP link; the provider
// also remembers the path so a subsequent generate(params.image=url) call
// can rehydrate it as a Gradio file descriptor.
async uploadFileToWan2gp(file) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
const buf = await file.arrayBuffer();
return window.localAI.wan2gp.uploadFile({
name: file.name,
type: file.type,
bytes: new Uint8Array(buf),
});
}
// ── Unified model list (both providers merged) ────────────────────────
async listModels() {
if (!isLocalAIAvailable()) return [];
const [sdcpp, wan2gp] = await Promise.all([
window.localAI.listModels(),
window.localAI.wan2gp.listModels().catch(() => []),
]);
return [
...sdcpp.map(m => ({ ...m, provider: m.provider || 'sdcpp' })),
...wan2gp,
];
}
// ── Provider-aware generate ───────────────────────────────────────────
async generate(params) {
if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.');
const model = getLocalModelById(params.model);
if (model?.provider === 'wan2gp') {
return window.localAI.wan2gp.generate(params);
}
return window.localAI.generate(params);
}
cancelGeneration() {
if (!isLocalAIAvailable()) return;
// Ask both — only the running one reacts.
window.localAI.cancelGeneration();
window.localAI.wan2gp.cancelGeneration();
}
/**
* Subscribe to generation progress events.
* sd.cpp emits { step, totalSteps, progress, status }.
* Wan2GP emits { progress, status }.
*/
onProgress(callback) {
if (!isLocalAIAvailable()) return () => {};
return window.localAI.onProgress(callback);
}
onDownloadProgress(callback) {
if (!isLocalAIAvailable()) return () => {};
return window.localAI.onDownloadProgress(callback);
}
}
export const localAI = new LocalInferenceClient();
+176
View File
@@ -0,0 +1,176 @@
// Frontend-side local model catalog.
// Two providers:
// - sdcpp: bundled engine, weights live on disk
// - wan2gp: user-run remote Gradio server
// Mirrors electron/lib/modelCatalog.js (sd.cpp) and electron/lib/wan2gpProvider.js (wan2gp).
export const LOCAL_MODEL_CATALOG = [
// ── sd.cpp: Z-Image (Tongyi-MAI) ────────────────────────────────────────
{
id: 'z-image-turbo',
name: 'Z-Image Turbo',
description: 'WaveSpeed\'s featured local model — 6B params, ultra-fast 8-step generation. No API key needed.',
type: 'z-image',
provider: 'sdcpp',
filename: 'z_image_turbo-Q4_K.gguf',
sizeGB: 3.4,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 8,
defaultGuidance: 1.0,
tags: ['turbo', 'fast', 'local', 'featured'],
featured: true,
},
{
id: 'z-image-base',
name: 'Z-Image Base',
description: 'Full-quality 6B parameter model from Tongyi-MAI — higher detail, 50-step generation.',
type: 'z-image',
provider: 'sdcpp',
filename: 'z-image-Q4_K_M.gguf',
sizeGB: 3.5,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 50,
defaultGuidance: 7.5,
tags: ['high-quality', 'local', 'detailed'],
featured: true,
},
// ── sd.cpp: SD 1.5 (small, M2-friendly) ─────────────────────────────────
{
id: 'dreamshaper-8',
name: 'Dreamshaper 8',
description: 'Versatile SD 1.5 model — great for portraits, landscapes, and artistic styles.',
type: 'sd1',
provider: 'sdcpp',
filename: 'DreamShaper_8_pruned.safetensors',
sizeGB: 2.1,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 20,
defaultGuidance: 7.5,
tags: ['photorealistic', 'artistic', 'versatile'],
},
{
id: 'realistic-vision-v51',
name: 'Realistic Vision v5.1',
description: 'Highly photorealistic people and scenes, based on SD 1.5.',
type: 'sd1',
provider: 'sdcpp',
filename: 'realisticVisionV51_v51VAE.safetensors',
sizeGB: 2.1,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 25,
defaultGuidance: 7,
tags: ['photorealistic', 'portraits', 'people'],
},
{
id: 'anything-v5',
name: 'Anything v5',
description: 'High quality anime and illustration style image generation.',
type: 'sd1',
provider: 'sdcpp',
filename: 'Anything-v5.0-PRT.safetensors',
sizeGB: 2.1,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 20,
defaultGuidance: 7,
tags: ['anime', 'illustration', 'artistic'],
},
// ── sd.cpp: SDXL ────────────────────────────────────────────────────────
{
id: 'stable-diffusion-xl-base',
name: 'SDXL Base 1.0',
description: 'Official Stable Diffusion XL base model — higher resolution, excellent quality.',
type: 'sdxl',
provider: 'sdcpp',
filename: 'sd_xl_base_1.0.safetensors',
sizeGB: 6.9,
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 30,
defaultGuidance: 7.5,
tags: ['sdxl', 'high-quality', 'versatile'],
},
// ── Wan2GP: image models ────────────────────────────────────────────────
{
id: 'wan2gp:flux-dev',
name: 'Flux.1 Dev (Wan2GP)',
description: 'Image — FLUX.1 dev served by Wan2GP. Requires running Wan2GP server.',
type: 'image',
family: 'flux',
provider: 'wan2gp',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 28,
defaultGuidance: 3.5,
tags: ['image', 'flux', 'remote'],
},
{
id: 'wan2gp:qwen-image',
name: 'Qwen Image (Wan2GP)',
description: 'Image — Qwen-Image text-to-image served by Wan2GP.',
type: 'image',
family: 'qwen',
provider: 'wan2gp',
aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'],
defaultSteps: 30,
defaultGuidance: 4.0,
tags: ['image', 'qwen', 'remote'],
},
// ── Wan2GP: video models ────────────────────────────────────────────────
{
id: 'wan2gp:wan22-t2v',
name: 'Wan 2.2 (Text-to-Video)',
description: 'Video — Wan 2.2 text-to-video. Slow on consumer GPUs.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'text-to-video'],
},
{
id: 'wan2gp:wan22-i2v',
name: 'Wan 2.2 (Image-to-Video)',
description: 'Video — Wan 2.2 image-to-video. Provide a start frame.',
type: 'video',
family: 'wan',
provider: 'wan2gp',
needsImage: true,
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 25,
defaultGuidance: 5.0,
tags: ['video', 'wan', 'image-to-video'],
},
{
id: 'wan2gp:hunyuan-video',
name: 'Hunyuan Video (Wan2GP)',
description: 'Video — Hunyuan text-to-video via Wan2GP.',
type: 'video',
family: 'hunyuan',
provider: 'wan2gp',
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 30,
defaultGuidance: 6.0,
tags: ['video', 'hunyuan'],
},
{
id: 'wan2gp:ltx-video',
name: 'LTX Video (Wan2GP)',
description: 'Video — LTX text-to-video. Fastest video option in Wan2GP.',
type: 'video',
family: 'ltx',
provider: 'wan2gp',
aspectRatios: ['16:9', '1:1', '9:16'],
defaultSteps: 20,
defaultGuidance: 3.0,
tags: ['video', 'ltx', 'fast'],
},
];
export function getLocalModelById(id) {
return LOCAL_MODEL_CATALOG.find(m => m.id === id) || null;
}
export const isWan2gpModelId = (id) => getLocalModelById(id)?.provider === 'wan2gp';
export const isLocalModelId = (id) => !!getLocalModelById(id);
export const localT2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && !m.needsImage);
export const localI2VModels = LOCAL_MODEL_CATALOG.filter(m => m.provider === 'wan2gp' && m.type === 'video' && m.needsImage);
+5
View File
@@ -0,0 +1,5 @@
// Single source of truth lives in the studio workspace package.
// See packages/studio/src/models.js. This file exists only so the
// standalone (Electron/Vite) build's existing imports of "../lib/models"
// keep resolving without touching every consumer.
export * from "studio/src/models.js";
+557
View File
@@ -0,0 +1,557 @@
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';
export class MuapiClient {
constructor() {
// Ideally user provides this in settings
this.baseUrl = (typeof import.meta !== 'undefined' && import.meta.env?.DEV) ? '' : 'https://api.muapi.ai';
}
getKey() {
const key = window.__MUAPI_KEY__ || localStorage.getItem('muapi_key');
if (!key) throw new Error('API Key missing. Please set it in Settings.');
return key;
}
/**
* Generates an image (Text-to-Image or Image-to-Image)
* @param {Object} params
* @param {string} params.model
* @param {string} params.prompt
* @param {string} params.negative_prompt
* @param {string} params.aspect_ratio
* @param {number} params.steps
* @param {number} params.guidance_scale
* @param {number} params.seed
* @param {string} [params.image_url] - If present, treats as Image-to-Image
*/
async generateImage(params) {
const key = this.getKey();
// Resolve endpoint from model definition
const modelInfo = getModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
// Build payload matching the API's expected format
const finalPayload = {
prompt: params.prompt,
};
// Aspect ratio (send as string, the API handles it)
if (params.aspect_ratio) {
finalPayload.aspect_ratio = params.aspect_ratio;
}
// Resolution
if (params.resolution) {
finalPayload.resolution = params.resolution;
}
// Quality (used by seedream and similar models)
if (params.quality) {
finalPayload.quality = params.quality;
}
// Image-to-Image
if (params.image_url) {
finalPayload.image_url = params.image_url;
finalPayload.strength = params.strength || 0.6;
} else {
finalPayload.image_url = null;
}
// Optional params if supported by model
if (params.seed && params.seed !== -1) {
finalPayload.seed = params.seed;
}
console.log('[Muapi] Requesting:', url);
console.log('[Muapi] Payload:', finalPayload);
try {
// Step 1: Submit the task
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': key
},
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
console.error('[Muapi] API Error Body:', errText);
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] Submit Response:', submitData);
// Extract request_id for polling
const requestId = submitData.request_id || submitData.id;
if (!requestId) {
// Some endpoints return the result directly
return submitData;
}
// Notify caller of requestId so they can persist it before polling begins
if (params.onRequestId) params.onRequestId(requestId);
// Step 2: Poll for results
console.log('[Muapi] Polling for results, request_id:', requestId);
const result = await this.pollForResult(requestId, key);
// Normalize: extract image URL from outputs array
const imageUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] Image URL:', imageUrl);
return { ...result, url: imageUrl };
} catch (error) {
console.error("Muapi Client Error:", error);
throw error;
}
}
/**
* Polls the predictions endpoint until the result is ready.
* @param {string} requestId - The request ID from the submit response
* @param {string} key - The API key
* @param {number} maxAttempts - Maximum polling attempts (default 60 = ~2 min)
* @param {number} interval - Polling interval in ms (default 2000)
*/
async pollForResult(requestId, key, maxAttempts = 60, interval = 2000) {
const pollUrl = `${this.baseUrl}/api/v1/predictions/${requestId}/result`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, interval));
console.log(`[Muapi] Polling attempt ${attempt}/${maxAttempts}...`);
try {
const response = await fetch(pollUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-api-key': key
}
});
if (!response.ok) {
const errText = await response.text();
console.warn(`[Muapi] Poll error (${response.status}):`, errText);
// Continue polling on non-fatal errors
if (response.status >= 500) continue;
throw new Error(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
console.log('[Muapi] Poll Response:', data);
const status = data.status?.toLowerCase();
if (status === 'completed' || status === 'succeeded' || status === 'success') {
return data;
}
if (status === 'failed' || status === 'error') {
throw new Error(`Generation failed: ${data.error || 'Unknown error'}`);
}
// Otherwise (processing, pending, etc.) keep polling
} catch (error) {
if (attempt === maxAttempts) throw error;
console.warn('[Muapi] Poll attempt failed, retrying...', error.message);
}
}
throw new Error('Generation timed out after polling.');
}
async generateVideo(params) {
const key = this.getKey();
const modelInfo = getVideoModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const finalPayload = {};
if (params.prompt) finalPayload.prompt = params.prompt;
if (params.request_id) finalPayload.request_id = params.request_id;
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.duration) finalPayload.duration = params.duration;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
if (params.mode) finalPayload.mode = params.mode;
if (params.image_url) finalPayload.image_url = params.image_url;
console.log('[Muapi] Video Request:', url);
console.log('[Muapi] Video Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': key
},
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
console.error('[Muapi] API Error Body:', errText);
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] Video Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
console.log('[Muapi] Polling for video results, request_id:', requestId);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] Video URL:', videoUrl);
return { ...result, url: videoUrl };
} catch (error) {
console.error("Muapi Video Client Error:", error);
throw error;
}
}
/**
* Generates an image using an Image-to-Image model.
* The model's imageField determines which payload key receives the uploaded image URL.
* @param {Object} params
* @param {string} params.model - i2iModel id
* @param {string} params.image_url - The uploaded reference image URL
* @param {string} [params.prompt] - Optional text prompt
* @param {string} [params.aspect_ratio]
* @param {string} [params.resolution]
*/
async generateI2I(params) {
const key = this.getKey();
const modelInfo = getI2IModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const finalPayload = {};
// Only include prompt if the model supports it and one was provided
finalPayload.prompt = params.prompt || '';
// Place the uploaded image(s) in the correct field for this model
const imageField = modelInfo?.imageField || 'image_url';
const imagesList = params.images_list?.length > 0 ? params.images_list : (params.image_url ? [params.image_url] : null);
if (imagesList) {
if (imageField === 'images_list') {
finalPayload.images_list = imagesList;
} else {
finalPayload[imageField] = imagesList[0];
}
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
console.log('[Muapi] I2I Request:', url);
console.log('[Muapi] I2I Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] I2I Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key);
const imageUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] I2I Result URL:', imageUrl);
return { ...result, url: imageUrl };
} catch (error) {
console.error('Muapi I2I Error:', error);
throw error;
}
}
/**
* Generates a video using an Image-to-Video model.
* @param {Object} params
* @param {string} params.model - i2vModel id
* @param {string} params.image_url - The uploaded start frame image URL
* @param {string} [params.prompt]
* @param {string} [params.aspect_ratio]
* @param {string} [params.resolution]
* @param {number} [params.duration]
* @param {string} [params.quality]
*/
async generateI2V(params) {
const key = this.getKey();
const modelInfo = getI2VModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const finalPayload = {};
if (params.prompt) finalPayload.prompt = params.prompt;
// Place image in the correct field for this model
const imageField = modelInfo?.imageField || 'image_url';
if (params.images_list && params.images_list.length > 0) {
if (imageField === 'images_list') {
finalPayload.images_list = params.images_list;
} else {
finalPayload[imageField] = params.images_list[0];
}
} else if (params.image_url) {
if (imageField === 'images_list') {
finalPayload.images_list = [params.image_url];
} else {
finalPayload[imageField] = params.image_url;
}
}
// Optional end-frame image — only for models declaring lastImageField.
// Server-side param name varies (last_image vs end_image_url).
const lastImageField = modelInfo?.lastImageField;
if (lastImageField && params.last_image) {
if (lastImageField === 'images_list') {
if (!finalPayload.images_list) finalPayload.images_list = [];
if (finalPayload.images_list.indexOf(params.last_image) === -1) {
finalPayload.images_list.push(params.last_image);
}
} else {
finalPayload[lastImageField] = params.last_image;
}
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.duration) finalPayload.duration = params.duration;
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.quality) finalPayload.quality = params.quality;
if (params.mode) finalPayload.mode = params.mode;
if (params.name) finalPayload.name = params.name;
console.log('[Muapi] I2V Request:', url);
console.log('[Muapi] I2V Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] I2V Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] I2V Result URL:', videoUrl);
return { ...result, url: videoUrl };
} catch (error) {
console.error('Muapi I2V Error:', error);
throw error;
}
}
/**
* Uploads a file to muapi and returns the hosted URL.
* @param {File} file - The image file to upload
* @returns {Promise<string>} The hosted URL of the uploaded file
*/
async uploadFile(file) {
const key = this.getKey();
const url = `${this.baseUrl}/api/v1/upload_file`;
const formData = new FormData();
formData.append('file', file);
console.log('[Muapi] Uploading file:', file.name);
const response = await fetch(url, {
method: 'POST',
headers: { 'x-api-key': key },
body: formData
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
console.log('[Muapi] Upload response:', data);
const fileUrl = data.url || data.file_url || data.data?.url;
if (!fileUrl) throw new Error('No URL returned from file upload');
return fileUrl;
}
/**
* Processes a video through a Video-to-Video model.
* Single-input tools (e.g. watermark remover) only need `video_url`.
* Motion-control models additionally need `image_url` and (often) `prompt`.
* @param {Object} params
* @param {string} params.model - v2vModel id
* @param {string} params.video_url - The uploaded video URL
* @param {string} [params.image_url] - Reference image URL (motion-control models)
* @param {string} [params.prompt] - Motion description (motion-control models)
*/
async processV2V(params) {
const key = this.getKey();
const modelInfo = getV2VModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const videoField = modelInfo?.videoField || 'video_url';
const finalPayload = { [videoField]: params.video_url };
if (modelInfo?.imageField && params.image_url) {
finalPayload[modelInfo.imageField] = params.image_url;
}
if (modelInfo?.hasPrompt && params.prompt) {
finalPayload.prompt = params.prompt;
}
console.log('[Muapi] V2V Request:', url);
console.log('[Muapi] V2V Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] V2V Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] V2V Result URL:', videoUrl);
return { ...result, url: videoUrl };
} catch (error) {
console.error('Muapi V2V Error:', error);
throw error;
}
}
/**
* Processes lipsync / speech-to-video generation.
* Supports image+audio → video and video+audio → video models.
* @param {Object} params
* @param {string} params.model - lipsyncModel id
* @param {string} [params.image_url] - Portrait image URL (image-based models)
* @param {string} [params.video_url] - Source video URL (video-based models)
* @param {string} params.audio_url - Audio file URL
* @param {string} [params.prompt] - Optional prompt (for models that support it)
* @param {string} [params.resolution] - Output resolution
* @param {number} [params.seed] - Optional seed (-1 for random)
* @param {Function} [params.onRequestId] - Called when request_id is received
*/
async processLipSync(params) {
const key = this.getKey();
const modelInfo = getLipSyncModelById(params.model);
const endpoint = modelInfo?.endpoint || params.model;
const url = `${this.baseUrl}/api/v1/${endpoint}`;
const finalPayload = {};
if (params.audio_url) finalPayload.audio_url = params.audio_url;
if (params.image_url) finalPayload.image_url = params.image_url;
if (params.video_url) finalPayload.video_url = params.video_url;
if (modelInfo?.hasPrompt) finalPayload.prompt = params.prompt || '';
if (params.resolution) finalPayload.resolution = params.resolution;
if (params.seed !== undefined && params.seed !== -1) finalPayload.seed = params.seed;
console.log('[Muapi] LipSync Request:', url);
console.log('[Muapi] LipSync Payload:', finalPayload);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(finalPayload)
});
if (!response.ok) {
const errText = await response.text();
console.error('[Muapi] LipSync API Error:', errText);
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
console.log('[Muapi] LipSync Submit Response:', submitData);
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] LipSync Result URL:', videoUrl);
return { ...result, url: videoUrl };
} catch (error) {
console.error('Muapi LipSync Error:', error);
throw error;
}
}
getDimensionsFromAR(ar) {
// Base unit 1024 (Flux standard)
switch (ar) {
case '1:1': return [1024, 1024];
case '16:9': return [1280, 720]; // 1024*1024 area approx
case '9:16': return [720, 1280];
case '4:3': return [1152, 864];
case '3:2': return [1216, 832];
case '21:9': return [1536, 640];
default: return [1024, 1024];
}
}
}
export const muapi = new MuapiClient();
+33
View File
@@ -0,0 +1,33 @@
const PENDING_KEY = 'muapi_pending_jobs';
export function savePendingJob(job) {
try {
const jobs = getAllPendingJobs().filter(j => j.requestId !== job.requestId);
jobs.push(job);
localStorage.setItem(PENDING_KEY, JSON.stringify(jobs));
} catch (e) {
console.warn('[PendingJobs] Failed to save:', e);
}
}
export function removePendingJob(requestId) {
try {
const jobs = getAllPendingJobs().filter(j => j.requestId !== requestId);
localStorage.setItem(PENDING_KEY, JSON.stringify(jobs));
} catch (e) {
console.warn('[PendingJobs] Failed to remove:', e);
}
}
export function getPendingJobs(studioType) {
const all = getAllPendingJobs();
return studioType ? all.filter(j => j.studioType === studioType) : all;
}
function getAllPendingJobs() {
try {
return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]');
} catch {
return [];
}
}
+92
View File
@@ -0,0 +1,92 @@
export const ENHANCE_TAGS = {
quality: ['professional photography', 'ultra-detailed', '8K resolution', 'high dynamic range', 'award-winning'],
lighting: ['cinematic lighting', 'golden hour', 'dramatic studio lighting', 'soft diffused light', 'neon glow', 'volumetric rays'],
mood: ['moody atmosphere', 'serene and peaceful', 'epic and dramatic', 'warm and cozy', 'dark and mysterious'],
style: ['photorealistic', 'oil painting style', 'watercolor', 'digital art', 'concept art', 'anime style', 'cyberpunk aesthetic'],
};
export const QUICK_PROMPTS = [
{ label: 'Portrait', prompt: 'Professional portrait photograph, shallow depth of field, soft studio lighting, 85mm lens' },
{ label: 'Landscape', prompt: 'Breathtaking landscape photograph, golden hour, wide angle, dramatic clouds, 4K' },
{ label: 'Product', prompt: 'Commercial product photography, clean white background, studio lighting, professional' },
{ label: 'Fantasy', prompt: 'Epic fantasy scene, magical atmosphere, volumetric lighting, highly detailed, concept art' },
{ label: 'Sci-Fi', prompt: 'Futuristic sci-fi environment, neon lights, cyberpunk city, rain reflections, cinematic' },
{ label: 'Food', prompt: 'Professional food photography, appetizing, warm lighting, shallow depth of field, editorial' },
{ label: 'Architecture', prompt: 'Architectural photography, dramatic angles, clean lines, modern design, professional' },
{ label: 'Fashion', prompt: 'High fashion editorial, avant-garde styling, studio lighting, Vogue aesthetic, professional' },
];
export const CAMERA_MAP = {
"Modular 8K Digital": "modular 8K digital cinema camera",
"Full-Frame Cine Digital": "full-frame digital cinema camera",
"Grand Format 70mm Film": "grand format 70mm film camera",
"Studio Digital S35": "Super 35 studio digital camera",
"Classic 16mm Film": "classic 16mm film camera",
"Premium Large Format Digital": "premium large-format digital cinema camera"
};
export const LENS_MAP = {
"Creative Tilt Lens": "creative tilt lens effect",
"Compact Anamorphic": "compact anamorphic lens",
"Extreme Macro": "extreme macro lens",
"70s Cinema Prime": "1970s cinema prime lens",
"Classic Anamorphic": "classic anamorphic lens",
"Premium Modern Prime": "premium modern prime lens",
"Warm Cinema Prime": "warm-toned cinema prime lens",
"Swirl Bokeh Portrait": "swirl bokeh portrait lens",
"Vintage Prime": "vintage prime lens",
"Halation Diffusion": "halation diffusion filter",
"Clinical Sharp Prime": "ultra-sharp clinical prime lens"
};
export const FOCAL_PERSPECTIVE = {
8: "ultra-wide perspective",
14: "wide-angle perspective",
24: "wide-angle dynamic perspective",
35: "natural cinematic perspective",
50: "standard portrait perspective",
85: "classic portrait perspective"
};
export const APERTURE_EFFECT = {
"f/1.4": "shallow depth of field, creamy bokeh",
"f/4": "balanced depth of field",
"f/11": "deep focus clarity, sharp foreground to background"
};
/**
* Compiles a cinematic prompt based on camera settings.
* @param {string} basePrompt
* @param {string} camera
* @param {string} lens
* @param {number} focalLength
* @param {string} aperture
* @returns {string} The compiled prompt
*/
export function buildNanoBananaPrompt(basePrompt, camera, lens, focalLength, aperture) {
const cameraDesc = CAMERA_MAP[camera] || camera;
const lensDesc = LENS_MAP[lens] || lens;
const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
const depthEffect = APERTURE_EFFECT[aperture] || "";
const qualityTags = [
"professional photography",
"ultra-detailed",
"8K resolution"
];
const parts = [
basePrompt,
`shot on a ${cameraDesc}`,
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ''}`,
`aperture ${aperture}`,
depthEffect,
"cinematic lighting",
"natural color science",
"high dynamic range",
qualityTags.join(", ")
];
// Filter out empty strings and join
return parts.filter(p => p && p.trim() !== "").join(", ");
}
+52
View File
@@ -0,0 +1,52 @@
const STORAGE_KEY = 'muapi_uploads';
const MAX_UPLOADS = 20;
export function getUploadHistory() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch {
return [];
}
}
export function saveUpload({ id, name, uploadedUrl, thumbnail, timestamp }) {
const history = getUploadHistory();
history.unshift({ id, name, uploadedUrl, thumbnail, timestamp });
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, MAX_UPLOADS)));
}
export function removeUpload(id) {
const history = getUploadHistory().filter(e => e.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
}
/**
* Generates a square 80×80 base64 JPEG thumbnail from a File.
* @param {File} file
* @returns {Promise<string|null>}
*/
export async function generateThumbnail(file) {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const SIZE = 80;
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
// Center-crop to square
const size = Math.min(img.width, img.height);
const sx = (img.width - size) / 2;
const sy = (img.height - size) / 2;
ctx.drawImage(img, sx, sy, size, size, 0, 0, SIZE, SIZE);
URL.revokeObjectURL(objectUrl);
resolve(canvas.toDataURL('image/jpeg', 0.6));
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
resolve(null);
};
img.src = objectUrl;
});
}
+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() };
}
+63
View File
@@ -0,0 +1,63 @@
import './style.css';
import { Header } from './components/Header.js';
import { ImageStudio } from './components/ImageStudio.js';
const app = document.querySelector('#app');
let contentArea;
// Router
function navigate(page) {
if (!contentArea) return;
contentArea.innerHTML = '';
if (page === 'image') {
contentArea.appendChild(ImageStudio());
} else if (page === 'video') {
import('./components/VideoStudio.js').then(({ VideoStudio }) => {
contentArea.appendChild(VideoStudio());
});
} else if (page === 'cinema') {
import('./components/CinemaStudio.js').then(({ CinemaStudio }) => {
contentArea.appendChild(CinemaStudio());
});
} else if (page === 'lipsync') {
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
contentArea.appendChild(LipSyncStudio());
});
} else if (page === 'workflows') {
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
contentArea.appendChild(WorkflowStudio());
});
} else if (page === 'agents') {
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
contentArea.appendChild(AgentStudio());
});
} else if (page === 'mcp-cli') {
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
contentArea.appendChild(McpCliStudio());
});
}
}
app.innerHTML = '';
// Pass navigate to Header so links work
app.appendChild(Header(navigate));
contentArea = document.createElement('main');
contentArea.id = 'content-area';
contentArea.className = 'flex-1 relative w-full overflow-hidden flex flex-col bg-app-bg';
app.appendChild(contentArea);
// Initial Route
navigate('image');
// Event Listener for Navigation
window.addEventListener('navigate', (e) => {
if (e.detail.page === 'settings') {
import('./components/SettingsModal.js').then(({ SettingsModal }) => {
document.body.appendChild(SettingsModal());
});
} else {
navigate(e.detail.page);
}
});
+29
View File
@@ -0,0 +1,29 @@
@import './styles/global.css';
@import './styles/studio.css';
/* App-specific layout */
#app {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-app);
}
main {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
}
/* Utilities */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
+234
View File
@@ -0,0 +1,234 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-app-bg text-white font-sans antialiased h-screen overflow-hidden;
}
/* Custom Scrollbar Refinement */
::-webkit-scrollbar {
@apply w-1.5 h-1.5;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-white/10 rounded-full hover:bg-white/20 transition-colors;
}
}
@layer components {
.glass {
@apply bg-black/80 backdrop-blur-2xl border border-white/10 shadow-3xl;
}
.glass-panel {
@apply bg-black/60 backdrop-blur-xl border border-white/5;
}
.neon-border {
@apply border border-primary/20 hover:border-primary/50 transition-colors;
}
.interactive-glow {
@apply transition-all duration-300 hover:shadow-glow hover:scale-[1.02] active:scale-[0.98];
}
}
/* Custom Animations */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
/* Thumbnail cards */
.thumb-hero {
position: relative;
overflow: hidden;
}
.thumb-hero img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}
.thumb-hero::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
pointer-events: none;
}
.thumb-hero:hover img,
.group:hover .thumb-hero img {
transform: scale(1.05);
}
.thumb-skeleton {
background: rgba(255, 255, 255, 0.03);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.thumb-fallback .thumb-hero {
display: none;
}
.hero-banner img {
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
}
.hero-banner:hover img {
transform: scale(1.03);
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up {
animation: none;
opacity: 1;
}
.thumb-hero img {
transition: none;
}
.thumb-skeleton {
animation: none;
}
}
/* ========================
TOOLTIP SYSTEM
======================== */
/* Base tooltip container */
[data-tooltip] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Tooltip arrow */
[data-tooltip]::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(8px);
border: 6px solid transparent;
border-top-color: #1a1a1a;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 9999;
}
/* Tooltip body */
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(12px);
padding: 8px 14px;
background: #1a1a1a;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
font-size: 12px;
font-weight: 600;
color: #fff;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 9999;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
/* Show tooltip on hover */
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
opacity: 1;
transform: translateX(-50%) translateY(-6px);
}
/* Tooltip positioning variants */
[data-tooltip-bottom]::before {
bottom: auto;
top: 100%;
border-top-color: transparent;
border-bottom-color: #1a1a1a;
transform: translateX(-50%) translateY(-8px);
}
[data-tooltip-bottom]::after {
bottom: auto;
top: 100%;
transform: translateX(-50%) translateY(-12px);
}
[data-tooltip-bottom]:hover::before,
[data-tooltip-bottom]:hover::after {
transform: translateX(-50%) translateY(6px);
}
/* Tooltip for left-aligned elements */
[data-tooltip-left]::before {
left: 0;
transform: translateX(-100%) translateY(-50%);
}
[data-tooltip-left]::after {
left: 0;
transform: translateX(calc(-100% - 10px)) translateY(-50%);
}
[data-tooltip-left]:hover::before,
[data-tooltip-left]:hover::after {
transform: translateX(calc(-100% - 6px)) translateY(-50%);
}
/* Tooltip for right-aligned elements */
[data-tooltip-right]::before {
left: auto;
right: 0;
transform: translateX(100%) translateY(-50%);
}
[data-tooltip-right]::after {
left: auto;
right: 0;
transform: translateX(calc(100% + 10px)) translateY(-50%);
}
[data-tooltip-right]:hover::before,
[data-tooltip-right]:hover::after {
transform: translateX(calc(100% + 6px)) translateY(-50%);
}
+119
View File
@@ -0,0 +1,119 @@
/* Studio Specific Styles */
.style-card {
position: relative;
border-radius: var(--border-radius-lg);
overflow: hidden;
aspect-ratio: 16/10;
cursor: pointer;
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.style-card:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.style-card.active {
border-color: var(--color-primary);
box-shadow: var(--shadow-glow);
}
.style-card img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.7;
transition: opacity var(--transition-normal);
}
.style-card:hover img,
.style-card.active img {
opacity: 1;
}
.style-card .label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: white;
font-size: 0.75rem;
font-weight: 600;
}
/* Prompt Bar Layout */
.prompt-bar-container {
display: flex;
gap: 1rem;
width: 100%;
}
.prompt-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.prompt-field label {
font-size: 0.7rem;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-left: 0.25rem;
}
.prompt-field textarea {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--border-radius-md);
padding: 0.75rem;
color: white;
resize: none;
font-size: 0.85rem;
transition: all var(--transition-fast);
}
.prompt-field textarea:focus {
background: rgba(255, 255, 255, 0.05);
border-color: var(--color-primary);
outline: none;
}
/* Canvas Toolbar */
.canvas-toolbar {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity var(--transition-normal);
}
.result-wrapper:hover .canvas-toolbar {
opacity: 1;
}
.toolbar-btn {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
padding: 0.5rem;
border-radius: var(--border-radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.toolbar-btn:hover {
background: var(--color-primary);
color: black;
border-color: var(--color-primary);
}
+49
View File
@@ -0,0 +1,49 @@
:root {
/* Brand Colors */
--color-primary: #22d3ee;
/* Electric Cyan */
--color-primary-hover: #06b6d4;
--color-accent: #a855f7;
/* Creative Purple */
--color-accent-hover: #9333ea;
--color-danger: #ef4444;
/* Backgrounds - Strict Dark Mode */
--bg-app: #050505;
/* Deepest Black */
--bg-panel: #0a0a0a;
/* Panel Background */
--bg-card: #141414;
/* Card/Input Background */
--bg-glass: rgba(10, 10, 10, 0.8);
/* Text */
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #52525b;
/* UI Elements */
--border-color: #27272a;
--border-light: rgba(255, 255, 255, 0.1);
--border-radius-sm: 6px;
--border-radius-md: 10px;
--border-radius-lg: 16px;
--border-radius-xl: 24px;
--border-radius-full: 9999px;
/* Effects */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-glow: 0 0 20px rgba(176, 251, 93, 0.4);
--shadow-glow-accent: 0 0 20px rgba(168, 85, 247, 0.4);
--backdrop-blur: blur(20px);
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
/* Typography */
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
}