Spaces:
Running
Running
| import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; | |
| import type { MediaFile } from './types'; | |
| import { GenerationStatus } from './types'; | |
| import FileUploader from './components/FileUploader'; | |
| import MediaItem from './components/MediaItem'; | |
| import { generateCaption, refineCaption, checkCaptionQuality } from './services/geminiService'; | |
| import { generateCaptionQwen, refineCaptionQwen, checkQualityQwen } from './services/qwenService'; | |
| import { sendComfyPrompt } from './services/comfyService'; | |
| import { DownloadIcon, SparklesIcon, WandIcon, LoaderIcon, CopyIcon, UploadCloudIcon, XIcon, CheckCircleIcon, AlertTriangleIcon, StopIcon, TrashIcon } from './components/Icons'; | |
| import { DEFAULT_COMFY_WORKFLOW } from './constants/defaultWorkflow'; | |
| declare const process: { | |
| env: { API_KEY?: string; [key: string]: string | undefined; } | |
| }; | |
| declare global { | |
| interface AIStudio { | |
| hasSelectedApiKey: () => Promise<boolean>; | |
| openSelectKey: () => Promise<void>; | |
| } | |
| interface Window { JSZip: any; aistudio?: AIStudio; } | |
| } | |
| type ApiProvider = 'gemini' | 'qwen'; | |
| type OSType = 'windows' | 'linux'; | |
| const GEMINI_MODELS = [ | |
| { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro (High Quality)' }, | |
| { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Fast)' }, | |
| { id: 'gemini-2.5-pro-preview-09-2025', name: 'Gemini 2.5 Pro (Multimodal)' }, | |
| { id: 'gemini-2.5-flash-native-audio-preview-09-2025', name: 'Gemini 2.5 Flash (Multimedia Speed)' } | |
| ]; | |
| const QWEN_MODELS = [ | |
| { id: 'thesby/Qwen3-VL-8B-NSFW-Caption-V4.5', name: 'Thesby Qwen 3 VL 8B NSFW Caption V4.5' }, | |
| { id: 'huihui-ai/Huihui-Qwen3-VL-8B-Instruct-abliterated', name: 'Huihui Qwen 3 VL 8B Abliterated (Uncensored)' }, | |
| { id: 'Qwen/Qwen3-VL-8B-Instruct-FP8', name: 'Qwen 3 VL 8B FP8' }, | |
| ]; | |
| const DEFAULT_BULK_INSTRUCTIONS = `Dont use ambiguous language "perhaps" for example. Describe EVERYTHING visible: characters, clothing, actions, background, objects, lighting, and camera angle. Refrain from using generic phrases like "character, male, figure of" and use specific terminology: "woman, girl, boy, man". Do not mention the art style.`; | |
| const DEFAULT_REFINEMENT_INSTRUCTIONS = `Refine the caption to be more descriptive and cinematic. Ensure all colors and materials are mentioned.`; | |
| const App: React.FC = () => { | |
| // --- STATE --- | |
| const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]); | |
| const [triggerWord, setTriggerWord] = useState<string>('MyStyle'); | |
| const [apiProvider, setApiProvider] = useState<ApiProvider>('gemini'); | |
| const [geminiApiKey, setGeminiApiKey] = useState<string>(process.env.API_KEY || ''); | |
| const [geminiModel, setGeminiModel] = useState<string>(GEMINI_MODELS[0].id); | |
| const [hasSelectedKey, setHasSelectedKey] = useState<boolean>(false); | |
| // Qwen Options | |
| const [qwenEndpoint, setQwenEndpoint] = useState<string>(''); | |
| const [useCustomQwenModel, setUseCustomQwenModel] = useState<boolean>(false); | |
| const [customQwenModelId, setCustomQwenModelId] = useState<string>(''); | |
| const [qwenModel, setQwenModel] = useState<string>(QWEN_MODELS[0].id); | |
| const [qwenOsType, setQwenOsType] = useState<OSType>(() => navigator.userAgent.includes("Windows") ? 'windows' : 'linux'); | |
| const [qwenInstallDir, setQwenInstallDir] = useState<string>(() => navigator.userAgent.includes("Windows") ? 'C:\\AI\\qwen_local' : '/home/user/ai/qwen_local'); | |
| const [qwenMaxTokens, setQwenMaxTokens] = useState<number>(8192); | |
| const [qwen8Bit, setQwen8Bit] = useState<boolean>(false); | |
| const [qwenEager, setQwenEager] = useState<boolean>(false); | |
| const [qwenVideoFrameCount, setQwenVideoFrameCount] = useState<number>(8); | |
| // Offline Local Snapshot Options | |
| const [useOfflineSnapshot, setUseOfflineSnapshot] = useState<boolean>(false); | |
| const [snapshotPath, setSnapshotPath] = useState<string>(''); | |
| const [virtualModelName, setVirtualModelName] = useState<string>('thesby/Qwen3-VL-8B-NSFW-Caption-V4.5'); | |
| // ComfyUI Options | |
| const [isComfyEnabled, setIsComfyEnabled] = useState<boolean>(false); | |
| const [comfyUrl, setComfyUrl] = useState<string>('http://localhost:5000'); | |
| const [comfyWorkflow, setComfyWorkflow] = useState<any>(DEFAULT_COMFY_WORKFLOW); | |
| const [comfyWorkflowName, setComfyWorkflowName] = useState<string>('Default Workflow'); | |
| const [comfySeed, setComfySeed] = useState<number>(-1); | |
| const [comfySteps, setComfySteps] = useState<number>(4); | |
| const [activePreviewId, setActivePreviewId] = useState<string | null>(null); | |
| // Secure Bridge Options | |
| const [useSecureBridge, setUseSecureBridge] = useState<boolean>(false); | |
| const [isFirstTimeBridge, setIsFirstTimeBridge] = useState<boolean>(false); | |
| const [bridgeOsType, setBridgeOsType] = useState<OSType>(() => navigator.userAgent.includes("Windows") ? 'windows' : 'linux'); | |
| const [bridgeInstallPath, setBridgeInstallPath] = useState<string>('/mnt/Goon/captioner'); | |
| // Queue and Performance | |
| const [useRequestQueue, setUseRequestQueue] = useState<boolean>(true); | |
| const [concurrentTasks, setConcurrentTasks] = useState<number>(1); | |
| const [isQueueRunning, setIsQueueRunning] = useState<boolean>(false); | |
| // Dataset / Instructions | |
| const [bulkGenerationInstructions, setBulkGenerationInstructions] = useState<string>(DEFAULT_BULK_INSTRUCTIONS); | |
| const [bulkRefinementInstructions, setBulkRefinementInstructions] = useState<string>(DEFAULT_REFINEMENT_INSTRUCTIONS); | |
| const [autofitTextareas, setAutofitTextareas] = useState<boolean>(false); | |
| const [showSideBySidePreview, setShowSideBySidePreview] = useState<boolean>(false); | |
| const [datasetPrefix, setDatasetPrefix] = useState<string>('item'); | |
| const [isCharacterTaggingEnabled, setIsCharacterTaggingEnabled] = useState<boolean>(false); | |
| const [characterShowName, setCharacterShowName] = useState<string>(''); | |
| const [isExporting, setIsExporting] = useState<boolean>(false); | |
| const abortControllerRef = useRef<AbortController>(new AbortController()); | |
| // --- EFFECTS --- | |
| useEffect(() => { | |
| if (window.aistudio) { | |
| window.aistudio.hasSelectedApiKey().then(setHasSelectedKey); | |
| } | |
| const isHttps = window.location.protocol === 'https:'; | |
| if (!qwenEndpoint) { | |
| setQwenEndpoint(isHttps ? '' : 'http://localhost:8000/v1'); | |
| } | |
| }, [qwenEndpoint]); | |
| // Handle Modal Keyboard Navigation | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (!activePreviewId) return; | |
| if (e.key === 'ArrowRight') handleNextPreview(); | |
| if (e.key === 'ArrowLeft') handlePrevPreview(); | |
| if (e.key === 'Escape') setActivePreviewId(null); | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => window.removeEventListener('keydown', handleKeyDown); | |
| }, [activePreviewId, mediaFiles]); | |
| // --- MEMOIZED VALUES --- | |
| const hasValidConfig = useMemo(() => { | |
| if (apiProvider === 'gemini') return !!geminiApiKey; | |
| return qwenEndpoint !== ''; | |
| }, [apiProvider, geminiApiKey, qwenEndpoint]); | |
| const selectedFiles = useMemo(() => { | |
| return (mediaFiles || []).filter(mf => mf.isSelected); | |
| }, [mediaFiles]); | |
| const currentPreviewItem = useMemo(() => (mediaFiles || []).find(m => m.id === activePreviewId), [mediaFiles, activePreviewId]); | |
| const qwenEffectiveModel = useMemo(() => { | |
| if (useOfflineSnapshot) return virtualModelName; | |
| return useCustomQwenModel ? customQwenModelId : qwenModel; | |
| }, [useOfflineSnapshot, virtualModelName, useCustomQwenModel, customQwenModelId, qwenModel]); | |
| const qwenStartCommand = useMemo(() => { | |
| const isWin = qwenOsType === 'windows'; | |
| const path = qwenInstallDir.replace(/[\\/]+$/, ''); | |
| // Model logic for command | |
| const modelToLoad = useOfflineSnapshot ? snapshotPath : (useCustomQwenModel ? customQwenModelId : qwenModel); | |
| const activate = isWin ? `venv\\Scripts\\activate` : `source venv/bin/activate`; | |
| const python = isWin ? `python` : `python3`; | |
| const offlineEnv = isWin ? `set HF_HUB_OFFLINE=1` : `export HF_HUB_OFFLINE=1`; | |
| let args = `--model "${modelToLoad}" --max-model-len ${qwenMaxTokens}`; | |
| if (useOfflineSnapshot) { | |
| args += ` --served-model-name "${virtualModelName}"`; | |
| } | |
| if (qwen8Bit) args += ` --load-format bitsandbytes --quantization bitsandbytes`; | |
| if (qwenEager) args += ` --enforce-eager`; | |
| const baseCmd = isWin | |
| ? `cd /d "${path}" && ${useOfflineSnapshot ? `${offlineEnv} && ` : ''}${activate} && ${python} -m vllm.entrypoints.openai.api_server ${args}` | |
| : `cd "${path}" && ${useOfflineSnapshot ? `${offlineEnv} && ` : ''}${activate} && ${python} -m vllm.entrypoints.openai.api_server ${args}`; | |
| return baseCmd; | |
| }, [qwenOsType, qwenInstallDir, useCustomQwenModel, customQwenModelId, qwenModel, qwenMaxTokens, qwen8Bit, qwenEager, useOfflineSnapshot, snapshotPath, virtualModelName]); | |
| const bridgeStartCommand = useMemo(() => { | |
| const isWindows = bridgeOsType === 'windows'; | |
| const path = bridgeInstallPath.replace(/[\\/]+$/, ''); | |
| const activateCmd = isWindows ? `call venv\\Scripts\\activate` : `source venv/bin/activate`; | |
| const pipCmd = `pip install flask flask-cors requests`; | |
| const setupCmd = isWindows | |
| ? `python -m venv venv && ${activateCmd} && ${pipCmd}` | |
| : `python3 -m venv venv && ${activateCmd} && ${pipCmd}`; | |
| return isWindows | |
| ? `cd /d "${path}" && ${isFirstTimeBridge ? `${setupCmd} && ` : ''}${activateCmd} && python bridge.py` | |
| : `cd "${path}" && ${isFirstTimeBridge ? `${setupCmd} && ` : ''}${activateCmd} && python3 bridge.py`; | |
| }, [bridgeInstallPath, bridgeOsType, isFirstTimeBridge]); | |
| const isTunnelRequired = useMemo(() => { | |
| return window.location.protocol === 'https:' && (qwenEndpoint.includes('localhost') || qwenEndpoint.includes('127.0.0.1')); | |
| }, [qwenEndpoint]); | |
| // --- HANDLERS --- | |
| const handleSelectApiKey = async () => { | |
| if (window.aistudio) { | |
| await window.aistudio.openSelectKey(); | |
| setHasSelectedKey(true); | |
| } | |
| }; | |
| const updateFile = useCallback((id: string, updates: Partial<MediaFile>) => { | |
| setMediaFiles(prev => (prev || []).map(mf => (mf.id === id ? { ...mf, ...updates } : mf))); | |
| }, []); | |
| const handleFilesAdded = useCallback(async (files: File[]) => { | |
| const mediaUploads = files.filter(file => file.type.startsWith('image/') || file.type.startsWith('video/')); | |
| const newMediaFiles = await Promise.all(mediaUploads.map(async (file) => ({ | |
| id: `${file.name}-${Math.random()}`, | |
| file, | |
| previewUrl: URL.createObjectURL(file), | |
| caption: '', | |
| status: GenerationStatus.IDLE, | |
| isSelected: false, | |
| customInstructions: '', | |
| comfyStatus: 'idle' | |
| } as MediaFile))); | |
| setMediaFiles(prev => [...(prev || []), ...newMediaFiles]); | |
| }, []); | |
| const handleCheckQuality = useCallback(async (id: string) => { | |
| const fileToProcess = (mediaFiles || []).find(mf => mf.id === id); | |
| if (!hasValidConfig || !fileToProcess || !fileToProcess.caption) return; | |
| updateFile(id, { status: GenerationStatus.CHECKING, errorMessage: undefined }); | |
| try { | |
| const score = apiProvider === 'gemini' | |
| ? await checkCaptionQuality(fileToProcess.file, fileToProcess.caption, abortControllerRef.current.signal, geminiApiKey, geminiModel) | |
| : await checkQualityQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, fileToProcess.caption, qwenVideoFrameCount, abortControllerRef.current.signal); | |
| updateFile(id, { qualityScore: score, status: GenerationStatus.SUCCESS }); | |
| } catch (err: any) { | |
| if (err.name === 'AbortError' || err.message === 'AbortError') { | |
| updateFile(id, { status: GenerationStatus.IDLE, errorMessage: "Stopped by user" }); | |
| } else { | |
| updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message }); | |
| } | |
| } | |
| }, [mediaFiles, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, hasValidConfig, updateFile, geminiApiKey, geminiModel]); | |
| const handleGenerateCaption = useCallback(async (id: string, itemInstructions?: string) => { | |
| const fileToProcess = (mediaFiles || []).find(mf => mf.id === id); | |
| if (!hasValidConfig || !fileToProcess) return; | |
| updateFile(id, { status: GenerationStatus.GENERATING, errorMessage: undefined, qualityScore: undefined }); | |
| const combinedInstructions = `${bulkGenerationInstructions}\n\n${itemInstructions || ''}`.trim(); | |
| try { | |
| const caption = apiProvider === 'gemini' | |
| ? await generateCaption(fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, abortControllerRef.current.signal, geminiApiKey, geminiModel) | |
| : await generateCaptionQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, triggerWord, combinedInstructions, isCharacterTaggingEnabled, characterShowName, qwenVideoFrameCount, abortControllerRef.current.signal); | |
| updateFile(id, { caption, status: GenerationStatus.SUCCESS }); | |
| } catch (err: any) { | |
| if (err.name === 'AbortError' || err.message === 'AbortError') { | |
| updateFile(id, { status: GenerationStatus.IDLE, errorMessage: "Stopped by user" }); | |
| } else { | |
| updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message }); | |
| } | |
| } | |
| }, [mediaFiles, triggerWord, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, bulkGenerationInstructions, isCharacterTaggingEnabled, characterShowName, hasValidConfig, updateFile, geminiApiKey, geminiModel]); | |
| const handleRefineCaptionItem = useCallback(async (id: string, itemInstructions?: string) => { | |
| const fileToProcess = (mediaFiles || []).find(mf => mf.id === id); | |
| if (!hasValidConfig || !fileToProcess || !fileToProcess.caption) return; | |
| updateFile(id, { status: GenerationStatus.GENERATING, errorMessage: undefined }); | |
| const combinedInstructions = `${bulkRefinementInstructions}\n\n${itemInstructions || ''}`.trim(); | |
| try { | |
| const caption = apiProvider === 'gemini' | |
| ? await refineCaption(fileToProcess.file, fileToProcess.caption, combinedInstructions, abortControllerRef.current.signal, geminiApiKey, geminiModel) | |
| : await refineCaptionQwen('', qwenEndpoint, qwenEffectiveModel, fileToProcess.file, fileToProcess.caption, combinedInstructions, qwenVideoFrameCount, abortControllerRef.current.signal); | |
| updateFile(id, { caption, status: GenerationStatus.SUCCESS }); | |
| } catch (err: any) { | |
| if (err.name === 'AbortError' || err.message === 'AbortError') { | |
| updateFile(id, { status: GenerationStatus.IDLE, errorMessage: "Stopped by user" }); | |
| } else { | |
| updateFile(id, { status: GenerationStatus.ERROR, errorMessage: err.message }); | |
| } | |
| } | |
| }, [mediaFiles, apiProvider, qwenEndpoint, qwenEffectiveModel, qwenVideoFrameCount, bulkRefinementInstructions, hasValidConfig, updateFile, geminiApiKey, geminiModel]); | |
| // --- QUEUE CONTROLLER --- | |
| const runTasksInQueue = async (tasks: (() => Promise<void>)[]) => { | |
| setIsQueueRunning(true); | |
| const pool = new Set<Promise<void>>(); | |
| for (const task of tasks) { | |
| if (abortControllerRef.current.signal.aborted) break; | |
| const promise = task(); | |
| pool.add(promise); | |
| promise.finally(() => pool.delete(promise)); | |
| if (pool.size >= concurrentTasks) { | |
| await Promise.race(pool); | |
| } | |
| } | |
| await Promise.all(pool); | |
| setIsQueueRunning(false); | |
| }; | |
| const handleBulkGenerate = () => { | |
| const tasks = selectedFiles.map(file => () => handleGenerateCaption(file.id, file.customInstructions)); | |
| if (useRequestQueue) { | |
| runTasksInQueue(tasks); | |
| } else { | |
| tasks.forEach(t => t()); | |
| } | |
| }; | |
| const handleBulkRefine = () => { | |
| const tasks = selectedFiles.map(file => () => handleRefineCaptionItem(file.id, file.customInstructions)); | |
| if (useRequestQueue) { | |
| runTasksInQueue(tasks); | |
| } else { | |
| tasks.forEach(t => t()); | |
| } | |
| }; | |
| const handleBulkQualityCheck = () => { | |
| const tasks = selectedFiles.map(file => () => handleCheckQuality(file.id)); | |
| if (useRequestQueue) { | |
| runTasksInQueue(tasks); | |
| } else { | |
| tasks.forEach(t => t()); | |
| } | |
| }; | |
| const handleClearWorkflow = useCallback(() => { | |
| setComfyWorkflow(DEFAULT_COMFY_WORKFLOW); | |
| setComfyWorkflowName('Default Workflow'); | |
| }, []); | |
| const handleComfyPreview = useCallback(async (id: string) => { | |
| const item = (mediaFiles || []).find(m => m.id === id); | |
| if (!item || !comfyWorkflow || !comfyUrl) return; | |
| updateFile(id, { comfyStatus: 'generating', comfyErrorMessage: undefined }); | |
| try { | |
| const previewUrl = await sendComfyPrompt(comfyUrl, comfyWorkflow, item.caption, comfySeed, comfySteps, useSecureBridge, abortControllerRef.current.signal); | |
| updateFile(id, { comfyPreviewUrl: previewUrl, comfyStatus: 'success' }); | |
| } catch (err: any) { | |
| if (err.name === 'AbortError' || err.message === 'Aborted') { | |
| updateFile(id, { comfyStatus: 'idle', comfyErrorMessage: "Stopped" }); | |
| } else { | |
| updateFile(id, { comfyStatus: 'error', comfyErrorMessage: err.message }); | |
| } | |
| } | |
| }, [mediaFiles, comfyWorkflow, comfyUrl, comfySeed, comfySteps, useSecureBridge, updateFile]); | |
| const handleBulkPreview = () => { | |
| selectedFiles.forEach(file => handleComfyPreview(file.id)); | |
| }; | |
| const handleDeleteSelected = useCallback(() => { | |
| setMediaFiles(prev => { | |
| const remaining = (prev || []).filter(mf => !mf.isSelected); | |
| return remaining || []; | |
| }); | |
| }, []); | |
| const handleStopTasks = () => { | |
| abortControllerRef.current.abort(); | |
| abortControllerRef.current = new AbortController(); | |
| setIsQueueRunning(false); | |
| setMediaFiles(prev => (prev || []).map(mf => { | |
| if (mf.status === GenerationStatus.GENERATING || mf.status === GenerationStatus.CHECKING) { | |
| return { ...mf, status: GenerationStatus.IDLE, errorMessage: "Stopped by user" }; | |
| } | |
| if (mf.comfyStatus === 'generating') { | |
| return { ...mf, comfyStatus: 'idle', comfyErrorMessage: "Stopped" }; | |
| } | |
| return mf; | |
| })); | |
| }; | |
| const handleExportDataset = useCallback(async () => { | |
| if (selectedFiles.length === 0) return; | |
| const JSZip = (window as any).JSZip; | |
| if (!JSZip) return alert("JSZip not loaded."); | |
| setIsExporting(true); | |
| try { | |
| const zip = new JSZip(); | |
| const prefix = datasetPrefix.trim() || 'item'; | |
| selectedFiles.forEach((mf, idx) => { | |
| const fileExt = mf.file.name.split('.').pop() || 'dat'; | |
| const finalName = `${prefix}_${idx + 1}`; | |
| zip.file(`${finalName}.${fileExt}`, mf.file); | |
| zip.file(`${finalName}.txt`, mf.caption || ""); | |
| }); | |
| const content = await zip.generateAsync({ type: 'blob' }); | |
| const link = document.createElement('a'); | |
| link.href = URL.createObjectURL(content); | |
| link.download = `lora_dataset_${new Date().getTime()}.zip`; | |
| link.click(); | |
| } catch (err: any) { | |
| alert("Export failed: " + err.message); | |
| } finally { setIsExporting(false); } | |
| }, [selectedFiles, datasetPrefix]); | |
| const handleNextPreview = useCallback(() => { | |
| if (!activePreviewId || (mediaFiles || []).length <= 1) return; | |
| const currentIndex = mediaFiles.findIndex(m => m.id === activePreviewId); | |
| const nextIndex = (currentIndex + 1) % mediaFiles.length; | |
| setActivePreviewId(mediaFiles[nextIndex].id); | |
| }, [activePreviewId, mediaFiles]); | |
| const handlePrevPreview = useCallback(() => { | |
| if (!activePreviewId || (mediaFiles || []).length <= 1) return; | |
| const currentIndex = mediaFiles.findIndex(m => m.id === activePreviewId); | |
| const prevIndex = (currentIndex - 1 + mediaFiles.length) % mediaFiles.length; | |
| setActivePreviewId(mediaFiles[prevIndex].id); | |
| }, [activePreviewId, mediaFiles]); | |
| const downloadQwenSetupScript = () => { | |
| const isWin = qwenOsType === 'windows'; | |
| const content = isWin | |
| ? `@echo off\npython -m venv venv\ncall venv\\Scripts\\activate\npip install vllm bitsandbytes\necho Setup Complete.` | |
| : `#!/bin/bash\npython3 -m venv venv\nsource venv/bin/activate\npip install vllm bitsandbytes\necho Setup Complete.`; | |
| const filename = isWin ? 'setup_qwen.bat' : 'setup_qwen.sh'; | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const downloadBridgeScript = () => { | |
| const code = `import requests\nfrom flask import Flask, request, Response\nfrom flask_cors import CORS\napp = Flask(__name__)\nCORS(app)\nTARGET = "http://127.0.0.1:8188"\n@app.route('/', defaults={'path': ''}, methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS'])\n@app.route('/<path:path>', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS'])\ndef proxy(path):\n url = f"{TARGET}/{path}"\n headers = {k:v for k,v in request.headers.items() if k.lower() not in ['host', 'origin', 'referer']}\n resp = requests.request(method=request.method, url=url, headers=headers, data=request.get_data(), params=request.args, stream=True)\n return Response(resp.content, resp.status_code, [(n,v) for n,v in resp.headers.items() if n.lower() not in ['content-encoding','content-length','transfer-encoding','connection']])\nif __name__ == '__main__': app.run(port=5000, host='0.0.0.0')`; | |
| const blob = new Blob([code], { type: 'text/x-python' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'bridge.py'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| // --- RENDER --- | |
| return ( | |
| <div className="min-h-screen bg-gray-950 text-gray-100 font-sans p-4 sm:p-8"> | |
| {/* PREVIEW MODAL */} | |
| {activePreviewId && currentPreviewItem && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/95 backdrop-blur-sm animate-fade-in" onClick={() => setActivePreviewId(null)}> | |
| <div className="bg-gray-900 w-full max-w-6xl rounded-2xl border border-gray-700 overflow-hidden flex flex-col max-h-[95vh] animate-scale-up shadow-2xl relative" onClick={(e) => e.stopPropagation()}> | |
| <button onClick={handlePrevPreview} className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-4 bg-gray-800/80 hover:bg-indigo-600 rounded-full text-white shadow-2xl transition-all border border-white/5 active:scale-90"> | |
| <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M15 19l-7-7 7-7"/></svg> | |
| </button> | |
| <button onClick={handleNextPreview} className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-4 bg-gray-800/80 hover:bg-indigo-600 rounded-full text-white shadow-2xl transition-all border border-white/5 active:scale-90"> | |
| <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M9 5l7 7-7 7"/></svg> | |
| </button> | |
| <div className="px-6 py-4 border-b border-gray-800 flex justify-between items-center bg-gray-850"> | |
| <div className="flex items-center gap-4"> | |
| <SparklesIcon className="w-5 h-5 text-indigo-400" /> | |
| <div className="flex flex-col"> | |
| <h3 className="text-xs font-black uppercase tracking-widest text-gray-400">{(mediaFiles || []).findIndex(m => m.id === activePreviewId) + 1} of {mediaFiles.length}</h3> | |
| <h3 className="text-[11px] font-bold truncate max-w-md text-gray-500">{currentPreviewItem.file.name}</h3> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button onClick={handlePrevPreview} className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-xl text-[10px] font-black uppercase transition-all">Prev</button> | |
| <button onClick={handleNextPreview} className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-xl text-[10px] font-black uppercase transition-all">Next</button> | |
| <button onClick={() => setActivePreviewId(null)} className="ml-4 p-2 hover:bg-red-600/20 rounded-full transition-colors text-gray-500 hover:text-red-400"><XIcon className="w-5 h-5" /></button> | |
| </div> | |
| </div> | |
| <div className="flex-grow overflow-y-auto p-6 space-y-8 bg-black/40"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-8 h-[450px]"> | |
| <div className="bg-black rounded-2xl border border-gray-800 flex items-center justify-center overflow-hidden relative shadow-inner"> | |
| {currentPreviewItem.file.type.startsWith('video/') ? <video src={currentPreviewItem.previewUrl} className="max-h-full" controls /> : <img src={currentPreviewItem.previewUrl} className="max-h-full object-contain" />} | |
| <div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-3 py-1 rounded-lg text-[10px] font-black uppercase text-white/80 border border-white/5">Original Data</div> | |
| </div> | |
| <div className="bg-black rounded-2xl border border-gray-800 flex items-center justify-center relative overflow-hidden shadow-inner"> | |
| {currentPreviewItem.comfyPreviewUrl ? <img src={currentPreviewItem.comfyPreviewUrl} className="max-h-full object-contain" /> : <div className="text-xs uppercase text-gray-700 tracking-widest font-black">No Preview Rendered</div>} | |
| {currentPreviewItem.comfyStatus === 'generating' && <div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center gap-3"><LoaderIcon className="w-10 h-10 animate-spin text-orange-500" /><span className="text-xs font-black uppercase text-orange-400 tracking-widest">Rendering via ComfyUI...</span></div>} | |
| <div className="absolute top-3 left-3 bg-orange-600/70 backdrop-blur-md px-3 py-1 rounded-lg text-[10px] font-black uppercase text-white/90 border border-white/5">ComfyUI Render</div> | |
| </div> | |
| </div> | |
| <div className="space-y-6"> | |
| <textarea value={currentPreviewItem.caption} onChange={(e) => updateFile(currentPreviewItem.id, { caption: e.target.value })} className="w-full bg-gray-950 border border-gray-700 rounded-2xl p-6 text-sm h-40 outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-inner leading-relaxed" /> | |
| <div className="flex gap-4"> | |
| <input type="text" value={currentPreviewItem.customInstructions} onChange={(e) => updateFile(currentPreviewItem.id, { customInstructions: e.target.value })} placeholder="Refine caption instructions..." className="flex-grow bg-gray-800 border border-gray-700 rounded-xl px-5 py-3 text-sm outline-none focus:ring-1 focus:ring-indigo-500 shadow-sm" /> | |
| <button onClick={() => handleGenerateCaption(currentPreviewItem.id, currentPreviewItem.customInstructions)} className="px-8 py-3 bg-green-600 hover:bg-green-500 text-white rounded-xl text-xs font-black uppercase transition-all shadow-xl active:scale-95">Re-Generate</button> | |
| <button onClick={() => handleRefineCaptionItem(currentPreviewItem.id, currentPreviewItem.customInstructions)} className="px-8 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase transition-all shadow-xl active:scale-95">Refine</button> | |
| <button onClick={() => handleCheckQuality(currentPreviewItem.id)} className="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl text-xs font-black uppercase transition-all shadow-xl active:scale-95">Check Quality</button> | |
| <button onClick={() => handleComfyPreview(currentPreviewItem.id)} className="px-8 py-3 bg-orange-600 hover:bg-orange-500 text-white rounded-xl text-xs font-black uppercase transition-all shadow-xl active:scale-95">Preview</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <main className="max-w-6xl mx-auto space-y-8 animate-fade-in"> | |
| <section className="bg-gray-900 border border-gray-800 p-8 rounded-3xl shadow-2xl space-y-12"> | |
| <h2 className="text-3xl font-black flex items-center gap-4 uppercase tracking-tighter text-white">1. Global Settings & Actions</h2> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-16"> | |
| <div className="space-y-10"> | |
| <div> | |
| <label className="text-xs font-black text-gray-500 uppercase tracking-widest block mb-4">AI Provider</label> | |
| <div className="flex p-1.5 bg-black rounded-2xl border border-gray-800 shadow-inner"> | |
| <button onClick={() => setApiProvider('gemini')} className={`flex-1 py-3 text-xs font-black uppercase rounded-xl transition-all ${apiProvider === 'gemini' ? 'bg-indigo-600 text-white shadow-lg' : 'text-gray-600 hover:text-gray-400'}`}>Google Gemini</button> | |
| <button onClick={() => setApiProvider('qwen')} className={`flex-1 py-3 text-xs font-black uppercase rounded-xl transition-all ${apiProvider === 'qwen' ? 'bg-indigo-600 text-white shadow-lg' : 'text-gray-600 hover:text-gray-400'}`}>Local Qwen (GPU)</button> | |
| </div> | |
| </div> | |
| {apiProvider === 'gemini' ? ( | |
| <div className="bg-indigo-500/5 border border-indigo-500/20 p-6 rounded-3xl space-y-6 animate-slide-down shadow-xl"> | |
| <div className="space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Gemini Model Version</label> | |
| </div> | |
| <select | |
| value={geminiModel} | |
| onChange={(e) => setGeminiModel(e.target.value)} | |
| className="w-full p-3 bg-black border border-indigo-500/30 rounded-xl text-xs font-bold text-gray-300 shadow-inner focus:ring-1 focus:ring-indigo-500 outline-none" | |
| > | |
| {GEMINI_MODELS.map(m => <option key={m.id} value={m.id}>{m.name}</option>)} | |
| </select> | |
| </div> | |
| <div className="space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Gemini API Key</label> | |
| {geminiApiKey && <span className="flex items-center gap-1.5 text-[9px] font-black uppercase text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full"><CheckCircleIcon className="w-3 h-3"/> Configured</span>} | |
| </div> | |
| <div className="relative group"> | |
| <input | |
| type="password" | |
| value={geminiApiKey} | |
| onChange={(e) => setGeminiApiKey(e.target.value)} | |
| placeholder="Enter your Gemini API key here..." | |
| className="w-full py-4 px-5 bg-black border border-indigo-500/30 rounded-2xl text-xs font-mono shadow-inner focus:ring-1 focus:ring-indigo-500 outline-none hover:border-indigo-500/60 transition-all" | |
| /> | |
| <div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-indigo-400/50 group-hover:text-indigo-400 transition-colors"> | |
| <SparklesIcon className="w-5 h-5" /> | |
| </div> | |
| </div> | |
| </div> | |
| <p className="text-[10px] text-gray-500 flex items-center gap-1.5 px-1"> | |
| <AlertTriangleIcon className="w-3 h-3 text-indigo-400" /> | |
| Get an API key from | |
| <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline font-bold">Google AI Studio</a> | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="bg-gray-950 p-6 rounded-3xl border border-gray-800 space-y-6 animate-slide-down shadow-xl"> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Local Model Configuration</label> | |
| <div className="flex items-center gap-4"> | |
| <label className="flex items-center gap-2 cursor-pointer group"> | |
| <input type="checkbox" checked={useOfflineSnapshot} onChange={e => setUseOfflineSnapshot(e.target.checked)} className="h-4 w-4 rounded bg-gray-800 border-gray-700 text-indigo-600" /> | |
| <span className="text-[10px] font-bold text-orange-400 group-hover:text-orange-300">Use Offline Local Snapshot</span> | |
| </label> | |
| {!useOfflineSnapshot && ( | |
| <label className="flex items-center gap-2 cursor-pointer group"> | |
| <input type="checkbox" checked={useCustomQwenModel} onChange={e => setUseCustomQwenModel(e.target.checked)} className="h-4 w-4 rounded bg-gray-800 border-gray-700 text-indigo-600" /> | |
| <span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300">Custom Model ID</span> | |
| </label> | |
| )} | |
| </div> | |
| </div> | |
| {useOfflineSnapshot ? ( | |
| <div className="space-y-4 animate-slide-down"> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] font-black text-gray-700 uppercase">Snapshot Directory Path</label> | |
| <input type="text" value={snapshotPath} onChange={e => setSnapshotPath(e.target.value)} placeholder="/path/to/hf_cache/.../snapshots/hash..." className="w-full p-2.5 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" /> | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] font-black text-gray-700 uppercase">Virtual Model Name (Served Name)</label> | |
| <input type="text" value={virtualModelName} onChange={e => setVirtualModelName(e.target.value)} placeholder="org/model-id..." className="w-full p-2.5 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" /> | |
| </div> | |
| </div> | |
| ) : useCustomQwenModel ? ( | |
| <input type="text" value={customQwenModelId} onChange={e => setCustomQwenModelId(e.target.value)} placeholder="org/model-id..." className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" /> | |
| ) : ( | |
| <select value={qwenModel} onChange={e => setQwenModel(e.target.value)} className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs font-bold text-gray-300 shadow-inner"> | |
| {QWEN_MODELS.map(m => <option key={m.id} value={m.id}>{m.name}</option>)} | |
| </select> | |
| )} | |
| <div className="pt-4 border-t border-gray-800 space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <span className="text-[10px] font-black text-gray-600 uppercase">OS Type:</span> | |
| <div className="flex gap-2"> | |
| <button onClick={() => setQwenOsType('windows')} className={`px-3 py-1 text-[9px] font-black uppercase rounded-lg transition-all ${qwenOsType === 'windows' ? 'bg-indigo-600 text-white' : 'text-gray-600 hover:text-gray-400'}`}>Windows</button> | |
| <button onClick={() => setQwenOsType('linux')} className={`px-3 py-1 text-[9px] font-black uppercase rounded-lg transition-all ${qwenOsType === 'linux' ? 'bg-indigo-600 text-white' : 'text-gray-600 hover:text-gray-400'}`}>Linux</button> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-4 gap-4"> | |
| <div className="col-span-3 space-y-1"> | |
| <label className="text-[9px] font-black text-gray-700 uppercase">Install Path</label> | |
| <input type="text" value={qwenInstallDir} onChange={e => setQwenInstallDir(e.target.value)} className="w-full p-2.5 bg-black border border-gray-800 rounded-xl text-xs font-mono" /> | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] font-black text-gray-700 uppercase">Max Tokens</label> | |
| <input type="number" value={qwenMaxTokens} onChange={e => setQwenMaxTokens(Number(e.target.value))} className="w-full p-2.5 bg-black border border-gray-800 rounded-xl text-xs text-center" /> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-2"> | |
| <label className="flex items-center gap-2 cursor-pointer group"> | |
| <input type="checkbox" checked={qwen8Bit} onChange={e => setQwen8Bit(e.target.checked)} className="h-4 w-4 rounded bg-gray-800 text-indigo-600" /> | |
| <span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300">Enable 8-bit Quantization (bitsandbytes)</span> | |
| </label> | |
| <label className="flex items-center gap-2 cursor-pointer group"> | |
| <input type="checkbox" checked={qwenEager} onChange={e => setQwenEager(e.target.checked)} className="h-4 w-4 rounded bg-gray-950 text-indigo-600" /> | |
| <span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300">Enforce Eager Mode</span> | |
| </label> | |
| </div> | |
| <button onClick={downloadQwenSetupScript} className="w-full py-3 bg-green-700 hover:bg-green-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download Setup Script</button> | |
| <div className="space-y-2"> | |
| <label className="text-[9px] font-black text-gray-700 uppercase">Local Start Command:</label> | |
| <div className="relative group"> | |
| <div className="p-3 bg-black rounded-xl border border-gray-900 font-mono text-[10px] text-green-500/80 break-all leading-relaxed max-h-24 overflow-y-auto shadow-inner"> | |
| {qwenStartCommand} | |
| </div> | |
| <button onClick={() => navigator.clipboard.writeText(qwenStartCommand)} className="absolute top-2 right-2 p-1.5 bg-gray-800 hover:bg-gray-700 text-gray-400 rounded-lg opacity-0 group-hover:opacity-100 transition-all"><CopyIcon className="w-3.5 h-3.5"/></button> | |
| </div> | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Endpoint URL (Tunnel or Local)</label> | |
| <input type="text" value={qwenEndpoint} onChange={e => setQwenEndpoint(e.target.value)} placeholder="http://localhost:8000/v1" className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner focus:ring-1 focus:ring-indigo-500 outline-none" /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="space-y-6"> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-500 uppercase tracking-widest">Trigger Word</label> | |
| <input type="text" value={triggerWord} onChange={e => setTriggerWord(e.target.value)} className="w-full p-3 bg-gray-950 border border-gray-800 rounded-2xl text-sm font-bold shadow-inner" placeholder="MyStyle" /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-500 uppercase tracking-widest">File Prefix</label> | |
| <input type="text" value={datasetPrefix} onChange={e => setDatasetPrefix(e.target.value)} className="w-full p-3 bg-gray-950 border border-gray-800 rounded-2xl text-sm font-bold shadow-inner" placeholder="item" /> | |
| </div> | |
| </div> | |
| <div className="bg-gray-800/40 p-5 rounded-3xl border border-gray-800 space-y-4 shadow-xl"> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={isCharacterTaggingEnabled} onChange={(e) => setIsCharacterTaggingEnabled(e.target.checked)} className="h-6 w-6 rounded-lg bg-gray-900 border-gray-700 text-indigo-600 transition-all shadow-sm" /> | |
| <span className="text-xs font-black text-gray-500 uppercase tracking-wider group-hover:text-gray-300 transition-colors">Character Tagging</span> | |
| </label> | |
| {isCharacterTaggingEnabled && ( | |
| <div className="animate-slide-down"> | |
| <input type="text" value={characterShowName} onChange={(e) => setCharacterShowName(e.target.value)} placeholder="Enter show/series name..." className="w-full p-3 bg-gray-950 border border-gray-700 rounded-xl text-xs font-medium focus:ring-1 focus:ring-indigo-500 outline-none transition-all shadow-inner" /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-10"> | |
| <div className="space-y-8"> | |
| <div className="space-y-3"> | |
| <label className="text-xs font-black text-gray-500 uppercase tracking-widest block">System Instructions & Prompting</label> | |
| <textarea value={bulkGenerationInstructions} onChange={(e) => setBulkGenerationInstructions(e.target.value)} className="w-full p-5 bg-gray-950 border border-gray-800 rounded-3xl text-[13px] h-40 leading-relaxed resize-none outline-none focus:ring-2 focus:ring-indigo-500 shadow-inner" placeholder="Enter global captioning rules..." /> | |
| </div> | |
| <div className="space-y-3"> | |
| <label className="text-xs font-black text-indigo-400 uppercase tracking-widest block">Refinement Instructions</label> | |
| <textarea value={bulkRefinementInstructions} onChange={(e) => setBulkRefinementInstructions(e.target.value)} className="w-full p-5 bg-gray-950 border border-indigo-500/20 rounded-3xl text-[13px] h-40 leading-relaxed resize-none outline-none focus:ring-2 focus:ring-indigo-500 shadow-inner" placeholder="Enter instructions for refining existing captions..." /> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-6 pt-4 border-t border-gray-800"> | |
| <div className="flex flex-wrap gap-x-8 gap-y-4"> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={autofitTextareas} onChange={(e) => setAutofitTextareas(e.target.checked)} className="h-5 w-5 rounded-md bg-gray-900 border-gray-700 text-indigo-500 shadow-inner" /> | |
| <span className="text-xs font-bold text-gray-500 uppercase group-hover:text-gray-300 transition-colors">Autofit Textboxes</span> | |
| </label> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={showSideBySidePreview} onChange={(e) => setShowSideBySidePreview(e.target.checked)} className="h-5 w-5 rounded-md bg-gray-900 border-gray-700 text-indigo-500 shadow-inner" /> | |
| <span className="text-xs font-bold text-gray-500 uppercase group-hover:text-gray-300 transition-colors">Side-by-Side Comparison</span> | |
| </label> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={isComfyEnabled} onChange={(e) => setIsComfyEnabled(e.target.checked)} className="h-5 w-5 rounded-md bg-gray-900 border-gray-700 text-orange-500 shadow-inner" /> | |
| <span className="text-xs font-black text-orange-500 uppercase tracking-widest group-hover:text-orange-400 transition-colors">Enable ComfyUI Previews</span> | |
| </label> | |
| </div> | |
| <div className="bg-indigo-600/5 border border-indigo-600/20 p-6 rounded-3xl space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={useRequestQueue} onChange={(e) => setUseRequestQueue(e.target.checked)} className="h-5 w-5 rounded bg-gray-900 border-gray-700 text-indigo-500" /> | |
| <span className="text-xs font-black text-indigo-400 uppercase tracking-widest group-hover:text-indigo-300 transition-colors">Enable Request Queue</span> | |
| </label> | |
| {useRequestQueue && ( | |
| <div className="flex items-center gap-3"> | |
| <label className="text-[10px] font-black text-gray-600 uppercase">Concurrent Tasks</label> | |
| <input type="number" min="1" max="10" value={concurrentTasks} onChange={(e) => setConcurrentTasks(Number(e.target.value))} className="w-16 p-1 bg-black border border-gray-800 rounded text-center text-xs font-bold" /> | |
| </div> | |
| )} | |
| </div> | |
| <p className="text-[10px] text-gray-600 italic">Recommended for Gemini Free Tier or Local GPU to prevent rate limits or OOM errors.</p> | |
| </div> | |
| {isComfyEnabled && ( | |
| <div className="bg-orange-600/5 border border-orange-600/20 p-6 rounded-3xl space-y-6 animate-slide-down shadow-xl"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-600 uppercase">Endpoint</label> | |
| <input type="text" value={comfyUrl} onChange={(e) => setComfyUrl(e.target.value)} placeholder="http://127.0.0.1:8188" className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-600 uppercase">Workflow ({comfyWorkflowName})</label> | |
| <div className="flex gap-2"> | |
| <button onClick={() => document.getElementById('wf-up')?.click()} className="flex-1 py-2.5 bg-orange-600 hover:bg-orange-500 text-white rounded-xl shadow-lg transition-all active:scale-95 text-[10px] font-black uppercase tracking-widest">Load JSON</button> | |
| <button onClick={handleClearWorkflow} className="px-4 bg-gray-800 hover:bg-gray-700 text-gray-400 rounded-xl transition-all active:scale-95"><TrashIcon className="w-4 h-4"/></button> | |
| <input id="wf-up" type="file" accept=".json" onChange={(e) => { | |
| const f = e.target.files?.[0]; | |
| if (f) { | |
| const r = new FileReader(); | |
| r.onload = (ev) => { | |
| try { | |
| setComfyWorkflow(JSON.parse(ev.target?.result as string)); | |
| setComfyWorkflowName(f.name); | |
| } catch { alert("Invalid Workflow JSON"); } | |
| }; | |
| r.readAsText(f); | |
| } | |
| }} className="hidden" /> | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-600 uppercase">Default Seed (-1 for random)</label> | |
| <input type="number" value={comfySeed} onChange={(e) => setComfySeed(Number(e.target.value))} className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs shadow-inner" /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-black text-gray-600 uppercase">Steps</label> | |
| <input type="number" value={comfySteps} onChange={(e) => setComfySteps(Number(e.target.value))} className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs shadow-inner" /> | |
| </div> | |
| </div> | |
| {/* Secure Bridge Sub-section */} | |
| <div className="pt-6 border-t border-orange-600/10 space-y-6"> | |
| <div className="flex justify-between items-center"> | |
| <h3 className="text-[11px] font-black text-orange-400 uppercase tracking-widest">Secure Bridge (for HTTPS/Remote access)</h3> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={useSecureBridge} onChange={(e) => setUseSecureBridge(e.target.checked)} className="h-5 w-5 rounded bg-gray-900 border-gray-700 text-orange-500" /> | |
| <span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300 transition-colors">Enable Bridge Proxy</span> | |
| </label> | |
| </div> | |
| {useSecureBridge && ( | |
| <div className="space-y-6 animate-slide-down"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div className="space-y-2"> | |
| <label className="text-[9px] font-black text-gray-600 uppercase">Bridge OS</label> | |
| <div className="flex gap-2"> | |
| <button onClick={() => setBridgeOsType('windows')} className={`flex-1 py-2 text-[10px] font-black uppercase rounded-lg transition-all ${bridgeOsType === 'windows' ? 'bg-orange-600 text-white' : 'bg-gray-800 text-gray-500'}`}>Windows</button> | |
| <button onClick={() => setBridgeOsType('linux')} className={`flex-1 py-2 text-[10px] font-black uppercase rounded-lg transition-all ${bridgeOsType === 'linux' ? 'bg-orange-600 text-white' : 'bg-gray-800 text-gray-500'}`}>Linux</button> | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[9px] font-black text-gray-600 uppercase">Install Path</label> | |
| <input type="text" value={bridgeInstallPath} onChange={(e) => setBridgeInstallPath(e.target.value)} className="w-full p-3 bg-black border border-gray-800 rounded-xl text-xs font-mono shadow-inner" /> | |
| </div> | |
| </div> | |
| <div className="space-y-4"> | |
| <label className="flex items-center gap-3 cursor-pointer group"> | |
| <input type="checkbox" checked={isFirstTimeBridge} onChange={(e) => setIsFirstTimeBridge(e.target.checked)} className="h-4 w-4 rounded bg-gray-950 border-gray-800 text-orange-500" /> | |
| <span className="text-[10px] font-bold text-gray-500 group-hover:text-gray-300">First-time Setup (Include VENV & Pip Install)</span> | |
| </label> | |
| <div className="flex gap-4"> | |
| <button onClick={downloadBridgeScript} className="flex-1 py-3 bg-orange-700 hover:bg-orange-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg">Download Bridge.py</button> | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-[9px] font-black text-gray-700 uppercase tracking-widest">Start Command:</label> | |
| <div className="relative group"> | |
| <div className="p-3 bg-black rounded-xl border border-gray-900 font-mono text-[10px] text-green-500/80 break-all leading-relaxed shadow-inner"> | |
| {bridgeStartCommand} | |
| </div> | |
| <button onClick={() => navigator.clipboard.writeText(bridgeStartCommand)} className="absolute top-2 right-2 p-1.5 bg-gray-800 hover:bg-gray-700 text-gray-400 rounded-lg opacity-0 group-hover:opacity-100 transition-all"><CopyIcon className="w-3.5 h-3.5"/></button> | |
| </div> | |
| <p className="text-[9px] text-gray-600 italic">The bridge will proxy requests from this HTTPS app to your local HTTP ComfyUI server.</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="border-t border-gray-800 pt-10 flex flex-col gap-6"> | |
| <div className="flex flex-wrap gap-4 justify-end"> | |
| <button | |
| onClick={handleDeleteSelected} | |
| disabled={selectedFiles.length === 0} | |
| className="px-6 py-4 bg-red-600/20 hover:bg-red-600/30 border border-red-600/30 rounded-2xl text-[11px] font-black uppercase text-red-400 flex items-center gap-3 transition-all active:scale-95 shadow-lg disabled:opacity-20 disabled:grayscale" | |
| > | |
| <TrashIcon className="w-5 h-5"/> Delete Selected ({selectedFiles.length}) | |
| </button> | |
| <button onClick={handleStopTasks} className="px-6 py-4 bg-orange-600/20 hover:bg-orange-600/40 border border-orange-600/30 rounded-2xl text-[11px] font-black uppercase text-orange-400 flex items-center gap-3 transition-all active:scale-95 shadow-lg"><StopIcon className="w-5 h-5"/> Stop Tasks</button> | |
| <button onClick={handleBulkQualityCheck} disabled={selectedFiles.length === 0 || !hasValidConfig || isQueueRunning} className="px-6 py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-2xl text-[11px] font-black uppercase flex items-center gap-4 transition-all shadow-xl active:scale-95 disabled:opacity-40"> | |
| <CheckCircleIcon className="w-5 h-5" /> Check Quality Selected ({selectedFiles.length}) | |
| </button> | |
| <button onClick={handleBulkGenerate} disabled={selectedFiles.length === 0 || !hasValidConfig || isQueueRunning} className="px-10 py-4 bg-green-600 hover:bg-green-500 text-white rounded-2xl text-xs font-black uppercase flex items-center gap-4 transition-all shadow-2xl shadow-green-900/30 active:scale-95 disabled:opacity-40"> | |
| <SparklesIcon className="w-6 h-6" /> Generate Selected ({selectedFiles.length}) | |
| </button> | |
| <button onClick={handleBulkRefine} disabled={selectedFiles.length === 0 || !hasValidConfig || isQueueRunning} className="px-10 py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl text-xs font-black uppercase flex items-center gap-4 transition-all shadow-xl active:scale-95 disabled:opacity-40"> | |
| <WandIcon className="w-6 h-6" /> Refine Selected ({selectedFiles.length}) | |
| </button> | |
| </div> | |
| <div className="flex flex-wrap gap-4 justify-end"> | |
| {isComfyEnabled && ( | |
| <button onClick={handleBulkPreview} disabled={selectedFiles.length === 0} className="px-10 py-4 bg-orange-600 hover:bg-orange-500 text-white rounded-2xl text-xs font-black uppercase flex items-center gap-4 transition-all shadow-xl shadow-orange-900/20 active:scale-95 disabled:opacity-40"> | |
| <WandIcon className="w-6 h-6" /> Preview Selected ({selectedFiles.length}) | |
| </button> | |
| )} | |
| <button onClick={handleExportDataset} disabled={selectedFiles.length === 0 || isExporting} className="w-full sm:w-auto px-16 py-5 bg-indigo-700 hover:bg-indigo-600 text-white rounded-2xl text-xs font-black uppercase flex items-center justify-center gap-4 transition-all shadow-2xl active:scale-95 disabled:opacity-40"> | |
| {isExporting ? <LoaderIcon className="w-6 h-6 animate-spin" /> : <DownloadIcon className="w-6 h-6" />} | |
| {isExporting ? 'Packaging ZIP...' : 'Download Finished Dataset'} | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="bg-gray-900 border border-gray-800 p-8 rounded-3xl shadow-xl overflow-hidden relative"> | |
| <div className="absolute top-0 right-0 p-8 opacity-10 pointer-events-none"><UploadCloudIcon className="w-32 h-32" /></div> | |
| <h2 className="text-xl font-black mb-6 uppercase tracking-widest text-gray-400">2. Upload Source Media</h2> | |
| <FileUploader onFilesAdded={handleFilesAdded} /> | |
| </section> | |
| <section className="space-y-8 animate-slide-up min-h-[400px]"> | |
| {mediaFiles && mediaFiles.length > 0 ? ( | |
| <> | |
| <div className="flex justify-between items-center bg-gray-900/80 backdrop-blur-2xl p-6 rounded-3xl border border-gray-800 sticky top-4 z-40 shadow-[0_20px_50px_-10px_rgba(0,0,0,0.5)]"> | |
| <div className="flex items-center gap-4"> | |
| <div className="h-10 w-1.5 bg-indigo-500 rounded-full shadow-[0_0_15px_rgba(99,102,241,0.5)]"></div> | |
| <div className="flex flex-col"> | |
| <h2 className="text-2xl font-black text-white uppercase tracking-tighter leading-none">3. Data Curation Workspace</h2> | |
| <p className="text-[10px] font-black text-gray-600 uppercase tracking-widest mt-1">Ready for Parallel Processing ({mediaFiles.length} Loaded)</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-6"> | |
| <div className="flex items-center gap-3 bg-black px-6 py-3 rounded-2xl border border-gray-800 shadow-inner group active:scale-95 transition-all"> | |
| <input type="checkbox" id="sel-all" className="h-6 w-6 rounded-lg bg-gray-900 border-gray-700 text-indigo-600 transition-all cursor-pointer shadow-sm" checked={mediaFiles.length > 0 && mediaFiles.every(f => f.isSelected)} onChange={(e) => setMediaFiles(prev => (prev || []).map(mf => ({ ...mf, isSelected: e.target.checked })))} /> | |
| <label htmlFor="sel-all" className="text-xs font-black text-gray-500 cursor-pointer group-hover:text-gray-300 transition-colors uppercase tracking-widest">Select All Items</label> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-12"> | |
| {mediaFiles.map(item => ( | |
| <MediaItem | |
| key={item.id} | |
| item={item} | |
| autofit={autofitTextareas} | |
| isApiKeySet={hasValidConfig} | |
| isComfyEnabled={isComfyEnabled} | |
| showSideBySidePreview={showSideBySidePreview} | |
| onGenerate={handleGenerateCaption} | |
| onCheckQuality={handleCheckQuality} | |
| onPreview={handleComfyPreview} | |
| onCaptionChange={(id, cap) => updateFile(id, { caption: cap })} | |
| onCustomInstructionsChange={(id, ins) => updateFile(id, { customInstructions: ins })} | |
| onSelectionChange={(id, sel) => updateFile(id, { isSelected: sel })} | |
| onOpenPreviewModal={setActivePreviewId} | |
| /> | |
| ))} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center py-32 bg-gray-900/50 rounded-3xl border-2 border-dashed border-gray-800 text-gray-500 animate-pulse"> | |
| <UploadCloudIcon className="w-16 h-16 mb-6 opacity-20" /> | |
| <h3 className="text-lg font-black uppercase tracking-widest text-gray-700">No items uploaded yet</h3> | |
| <p className="text-xs mt-2 uppercase tracking-tight text-gray-600">Start by dropping files into the upload zone above</p> | |
| </div> | |
| )} | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| }; | |
| export default App; |