Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

<h3>Browser-based Video Compositor</h3>

[![Version](https://img.shields.io/badge/version-1.3.9-blue.svg)](https://github.com/Sportinger/MasterSelects/releases)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Lemonade Integration](https://img.shields.io/badge/Lemonade-Phase%202%20Authorized-orange.svg)](docs/lemonade/README.md)

<p>
GPU-first editing with <b>30 effects</b>, <b>37 blend modes</b>, <b>76 AI tools</b>, and only <b>13 dependencies</b>.<br>
Built from scratch in <b>2,500+ lines of WGSL</b> and <b>120k lines of TypeScript</b>.
Expand Down
27 changes: 7 additions & 20 deletions src/components/common/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Settings Dialog - After Effects style preferences with sidebar navigation

import { useState, useCallback, useRef } from 'react';
import { useSettingsStore } from '../../stores/settingsStore';
import { useState, useRef } from 'react';
import { useDraggableDialog } from './settings/useDraggableDialog';
import { AppearanceSettings } from './settings/AppearanceSettings';
import { GeneralSettings } from './settings/GeneralSettings';
Expand Down Expand Up @@ -52,37 +51,25 @@ export function SettingsDialog({ onClose }: SettingsDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const { position, isDragging, handleMouseDown } = useDraggableDialog(dialogRef);

const { apiKeys, setApiKey } = useSettingsStore();

// Local state for API keys (to avoid saving on every keystroke)
const [localKeys, setLocalKeys] = useState<{ [key: string]: string }>({ ...apiKeys });

const handleSave = useCallback(() => {
Object.entries(localKeys).forEach(([provider, key]) => {
setApiKey(provider as keyof typeof apiKeys, key);
});
onClose();
}, [localKeys, setApiKey, onClose]);

const handleKeyChange = (provider: string, value: string) => {
setLocalKeys((prev) => ({ ...prev, [provider]: value }));
};

const renderCategoryContent = () => {
switch (activeCategory) {
case 'appearance': return <AppearanceSettings />;
case 'general': return <GeneralSettings />;
case 'previews': return <PreviewsSettings />;
case 'import': return <ImportSettings />;
case 'transcription': return <TranscriptionSettings localKeys={localKeys} />;
case 'transcription': return <TranscriptionSettings />;
case 'output': return <OutputSettings />;
case 'performance': return <PerformanceSettings />;
case 'apiKeys': return <ApiKeysSettings localKeys={localKeys} onKeyChange={handleKeyChange} />;
case 'apiKeys': return <ApiKeysSettings />;
case 'aiFeatures': return <AIFeaturesSettings />;
default: return null;
}
};

const handleSave = () => {
onClose();
};

return (
<div className="settings-container">
<div
Expand Down
171 changes: 169 additions & 2 deletions src/components/common/settings/AIFeaturesSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useState, useCallback } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useSettingsStore, type LemonadeModel } from '../../../stores/settingsStore';
import { useMatAnyoneStore, type MatAnyoneSetupStatus } from '../../../stores/matanyoneStore';
import { lemonadeService } from '../../../services/lemonadeService';
import { MODEL_PRESETS } from '../../../services/lemonadeProvider';
import { Logger } from '../../../services/logger';

const log = Logger.create('AIFeaturesSettings');

function getStatusLabel(status: MatAnyoneSetupStatus): string {
switch (status) {
Expand Down Expand Up @@ -48,12 +53,52 @@ function getStatusColor(status: MatAnyoneSetupStatus): string {
}
}

function LemonadeStatusIndicator({ status }: { status: 'online' | 'offline' | 'checking' }) {
const statusConfig = {
online: { color: '#22c55e', label: 'Online' },
offline: { color: '#ef4444', label: 'Offline' },
checking: { color: '#f59e0b', label: 'Checking...' },
};

const config = statusConfig[status];

return (
<span style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 3,
background: `${config.color}22`,
color: config.color,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 4,
}}>
<span style={{
width: 6,
height: 6,
borderRadius: '50%',
background: config.color,
display: 'inline-block',
}} />
{config.label}
</span>
);
}

export function AIFeaturesSettings() {
const {
matanyoneEnabled,
matanyonePythonPath,
setMatAnyoneEnabled,
setMatAnyonePythonPath,
// Lemonade Server settings
aiProvider,
lemonadeModel,
lemonadeUseFallback,
setAiProvider,
setLemonadeModel,
setLemonadeUseFallback,
} = useSettingsStore();

const {
Expand All @@ -67,6 +112,56 @@ export function AIFeaturesSettings() {

const [advancedOpen, setAdvancedOpen] = useState(false);
const [confirmUninstall, setConfirmUninstall] = useState(false);
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'checking'>('checking');
const [testingConnection, setTestingConnection] = useState(false);

// Mount guard ref to prevent state updates on unmounted component
const isMountedRef = useRef(true);

// Subscribe to Lemonade Server status updates
useEffect(() => {
isMountedRef.current = true;

// Initial health check with mount guard
lemonadeService.checkHealth().then(health => {
if (isMountedRef.current) {
setServerStatus(health.status);
}
});

// Subscribe to status changes with mount guard
const unsubscribe = lemonadeService.subscribe(status => {
if (isMountedRef.current) {
setServerStatus(status.available ? 'online' : 'offline');
}
});

// Cleanup on unmount
return () => {
isMountedRef.current = false;
unsubscribe();
};
}, []);

// useCallback for Test Connection handler with proper dependency
const handleTestConnection = useCallback(async () => {
setTestingConnection(true);
try {
const health = await lemonadeService.refresh();
log.debug('Test connection result:', health);
} catch (error) {
log.error('Test connection failed:', error);
} finally {
setTestingConnection(false);
}
}, []);

// useCallback for Refresh Status handler
const handleRefreshStatus = useCallback(() => {
lemonadeService.checkHealth().then(health => {
setServerStatus(health.status);
});
}, []);

const isInstalled = setupStatus === 'installed' || setupStatus === 'ready'
|| setupStatus === 'model-needed' || setupStatus === 'starting';
Expand Down Expand Up @@ -286,6 +381,78 @@ export function AIFeaturesSettings() {
</div>
</>
)}

{/* Lemonade Server Section */}
<div className="settings-group" style={{ marginTop: '24px', borderTop: '1px solid var(--border)', paddingTop: '16px' }}>
<div className="settings-group-title">Lemonade Server - Local AI Inference</div>

<label className="settings-row">
<span className="settings-label">Use Lemonade for AI Chat</span>
<input
type="checkbox"
checked={aiProvider === 'lemonade'}
onChange={(e) => setAiProvider(e.target.checked ? 'lemonade' : 'openai')}
className="settings-checkbox"
/>
</label>
<p className="settings-hint">
Enable to use local AI inference instead of OpenAI API. Requires Lemonade Server running on your machine.
</p>

{aiProvider === 'lemonade' && (
<>
<div className="settings-row">
<span className="settings-label">Server Status</span>
<LemonadeStatusIndicator status={serverStatus} />
</div>

<label className="settings-row">
<span className="settings-label">Default Model</span>
<select
value={lemonadeModel}
onChange={(e) => setLemonadeModel(e.target.value as LemonadeModel)}
className="settings-select"
style={{ width: '280px' }}
>
{MODEL_PRESETS.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.name} ({preset.size}) - {preset.description}
</option>
))}
</select>
</label>

<label className="settings-row">
<span className="settings-label">Fast Fallback Mode</span>
<input
type="checkbox"
checked={lemonadeUseFallback}
onChange={(e) => setLemonadeUseFallback(e.target.checked)}
className="settings-checkbox"
/>
</label>
<p className="settings-hint">
Use smaller model for simple commands. Faster response, lower reasoning quality.
</p>

<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', padding: '4px 0' }}>
<button
className="settings-button"
onClick={handleTestConnection}
disabled={testingConnection}
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</button>
<button
className="settings-button"
onClick={handleRefreshStatus}
>
Refresh Status
</button>
</div>
</>
)}
</div>
</div>
);
}
Loading