| import React, { useEffect, useState, useCallback } from "react"; |
| import { ArrowLeft, X, ChevronDown } from "lucide-react"; |
| import { useStudioStore } from "./studio/stores/studioStore"; |
| import { CreatorStudioEditor } from "./CreatorStudioEditor"; |
| import { detectArchitecture, getArchitectureLabel, getModelSettings } from "./modelPresets"; |
| |
| |
| |
| |
| |
| |
| |
| export function CreatorStudioHost({ backendUrl, apiKey, projectId: initialProjectId, onExit, }) { |
| const authKey = (apiKey || "").trim(); |
| |
| const [mode, setMode] = useState(initialProjectId ? "editor" : "wizard"); |
| const [currentProjectId, setCurrentProjectId] = useState(initialProjectId); |
| const [isNewlyCreated, setIsNewlyCreated] = useState(false); |
| const [projectSettings, setProjectSettings] = useState({ |
| targetSceneCount: 8, |
| sceneDuration: 5, |
| llmModel: "", |
| imageModel: "", |
| videoModel: "", |
| enableVideoGeneration: false, |
| imageWidth: undefined, |
| imageHeight: undefined, |
| }); |
| |
| useEffect(() => { |
| const store = useStudioStore.getState(); |
| if (store.setConnection) { |
| store.setConnection(backendUrl, authKey); |
| } |
| }, [backendUrl, authKey]); |
| |
| if (mode === "editor" && currentProjectId) { |
| return (<CreatorStudioEditor projectId={currentProjectId} backendUrl={backendUrl} apiKey={apiKey} onExit={onExit} autoGenerateFirst={isNewlyCreated} targetSceneCount={projectSettings.targetSceneCount} defaultLLMModel={projectSettings.llmModel} imageModel={projectSettings.imageModel} videoModel={projectSettings.videoModel} enableVideoGeneration={projectSettings.enableVideoGeneration} imageWidth={projectSettings.imageWidth} imageHeight={projectSettings.imageHeight}/>); |
| } |
| |
| return (<CreatorStudioWizard backendUrl={backendUrl} apiKey={apiKey} onExit={onExit} onProjectCreated={(projectId, settings) => { |
| // Switch to editor mode with the new project |
| setCurrentProjectId(projectId); |
| setIsNewlyCreated(true); // Flag for auto-generating first scene |
| // Ensure imageWidth/imageHeight are always present (may be undefined) |
| setProjectSettings({ |
| ...settings, |
| imageWidth: settings.imageWidth, |
| imageHeight: settings.imageHeight, |
| }); |
| setMode("editor"); |
| }}/>); |
| } |
| function CreatorStudioWizard({ backendUrl, apiKey, onExit, onProjectCreated, }) { |
| const authKey = (apiKey || "").trim(); |
| |
| const [step, setStep] = useState(0); |
| |
| const [projectType, setProjectType] = useState("video"); |
| |
| const [title, setTitle] = useState(""); |
| const [logline, setLogline] = useState(""); |
| const [platformPreset, setPlatformPreset] = useState("youtube_16_9"); |
| const [contentRating, setContentRating] = useState("sfw"); |
| const [allowMature, setAllowMature] = useState(false); |
| const [localOnly, setLocalOnly] = useState(true); |
| |
| const [goal, setGoal] = useState("Educate"); |
| const [tones, setTones] = useState(["Documentary", "Calm"]); |
| const [visualStyle, setVisualStyle] = useState("Cinematic"); |
| const [lockIdentity, setLockIdentity] = useState(true); |
| |
| const [targetSceneCount, setTargetSceneCount] = useState(8); |
| const [sceneDuration, setSceneDuration] = useState(5); |
| |
| const [availableLLMModels, setAvailableLLMModels] = useState([]); |
| const [selectedLLMModel, setSelectedLLMModel] = useState(""); |
| const [loadingModels, setLoadingModels] = useState(false); |
| |
| const [availableImageModels, setAvailableImageModels] = useState([]); |
| const [selectedImageModel, setSelectedImageModel] = useState(""); |
| const [loadingImageModels, setLoadingImageModels] = useState(false); |
| |
| const [availableVideoModels, setAvailableVideoModels] = useState([]); |
| const [selectedVideoModel, setSelectedVideoModel] = useState(""); |
| const [loadingVideoModels, setLoadingVideoModels] = useState(false); |
| |
| const [enableVideoGeneration, setEnableVideoGeneration] = useState(true); |
| |
| const [matureCategory, setMatureCategory] = useState("fan_service"); |
| const [intensityLevel, setIntensityLevel] = useState(0.3); |
| |
| const [showMatureModal, setShowMatureModal] = useState(false); |
| const [matureConsentChecked, setMatureConsentChecked] = useState(false); |
| |
| const [loading, setLoading] = useState(false); |
| const [error, setError] = useState(null); |
| |
| const [generatingOutline, setGeneratingOutline] = useState(false); |
| const [generatedOutline, setGeneratedOutline] = useState(null); |
| const [tempProjectId, setTempProjectId] = useState(null); |
| |
| const fetchLLMModels = useCallback(async () => { |
| setLoadingModels(true); |
| try { |
| const url = `${backendUrl.replace(/\/+$/, "")}/models?provider=ollama`; |
| const res = await fetch(url, { |
| headers: authKey ? { "x-api-key": authKey } : {}, |
| }); |
| if (res.ok) { |
| const data = await res.json(); |
| if (data.models) { |
| |
| const models = data.models.map((m) => { |
| |
| if (typeof m === 'string') { |
| return { id: m, name: m }; |
| } |
| return { id: m.id, name: m.name || m.id }; |
| }); |
| setAvailableLLMModels(models); |
| |
| if (!selectedLLMModel && models.length > 0) { |
| |
| const preferred = models.find((m) => m.id.includes("llama3")) || models[0]; |
| setSelectedLLMModel(preferred.id); |
| } |
| } |
| } |
| } |
| catch (e) { |
| console.log("[Wizard] Failed to fetch LLM models:", e); |
| } |
| finally { |
| setLoadingModels(false); |
| } |
| }, [backendUrl, authKey, selectedLLMModel]); |
| |
| const fetchImageModels = useCallback(async () => { |
| setLoadingImageModels(true); |
| try { |
| const url = `${backendUrl.replace(/\/+$/, "")}/models?provider=comfyui&model_type=image`; |
| const res = await fetch(url, { |
| headers: authKey ? { "x-api-key": authKey } : {}, |
| }); |
| if (res.ok) { |
| const data = await res.json(); |
| if (data.models) { |
| const models = data.models.map((m) => ({ |
| id: m, |
| name: m, |
| })); |
| setAvailableImageModels(models); |
| |
| if (!selectedImageModel && models.length > 0) { |
| |
| const preferred = models.find((m) => m.id.toLowerCase().includes("dreamshaper")) || models[0]; |
| setSelectedImageModel(preferred.id); |
| } |
| } |
| } |
| } |
| catch (e) { |
| console.log("[Wizard] Failed to fetch image models:", e); |
| } |
| finally { |
| setLoadingImageModels(false); |
| } |
| }, [backendUrl, authKey, selectedImageModel]); |
| |
| const fetchVideoModels = useCallback(async () => { |
| setLoadingVideoModels(true); |
| try { |
| const url = `${backendUrl.replace(/\/+$/, "")}/models?provider=comfyui&model_type=video`; |
| const res = await fetch(url, { |
| headers: authKey ? { "x-api-key": authKey } : {}, |
| }); |
| if (res.ok) { |
| const data = await res.json(); |
| if (data.models) { |
| const models = data.models.map((m) => ({ |
| id: m, |
| name: m, |
| })); |
| setAvailableVideoModels(models); |
| |
| if (!selectedVideoModel && models.length > 0) { |
| setSelectedVideoModel(models[0].id); |
| } |
| } |
| } |
| } |
| catch (e) { |
| console.log("[Wizard] Failed to fetch video models:", e); |
| } |
| finally { |
| setLoadingVideoModels(false); |
| } |
| }, [backendUrl, authKey, selectedVideoModel]); |
| |
| useEffect(() => { |
| fetchLLMModels(); |
| fetchImageModels(); |
| fetchVideoModels(); |
| }, [fetchLLMModels, fetchImageModels, fetchVideoModels]); |
| |
| const tagsForBackend = React.useMemo(() => { |
| const t = []; |
| |
| t.push(`projectType:${projectType}`); |
| |
| t.push(`mode:${projectType === "slideshow" ? "slideshow" : "video"}`); |
| if (goal) |
| t.push(`goal:${goal.toLowerCase()}`); |
| if (visualStyle) |
| t.push(`visual:${visualStyle.toLowerCase().replaceAll(" ", "_")}`); |
| if (tones.length) |
| t.push(...tones.map((x) => `tone:${x.toLowerCase().replaceAll(" ", "_")}`)); |
| if (lockIdentity) |
| t.push("lock:identity"); |
| |
| t.push(`scenes:${targetSceneCount}`); |
| t.push(`duration:${sceneDuration}`); |
| |
| if (selectedLLMModel) |
| t.push(`llm:${selectedLLMModel}`); |
| if (selectedImageModel) |
| t.push(`imageModel:${selectedImageModel}`); |
| |
| t.push(`videoGeneration:${enableVideoGeneration ? 'enabled' : 'disabled'}`); |
| if (enableVideoGeneration && selectedVideoModel) |
| t.push(`videoModel:${selectedVideoModel}`); |
| |
| if (contentRating === "mature") { |
| t.push(`mature:enabled`); |
| t.push(`matureCategory:${matureCategory}`); |
| t.push(`intensity:${intensityLevel.toFixed(2)}`); |
| } |
| return Array.from(new Set(t)); |
| }, [projectType, goal, visualStyle, tones, lockIdentity, targetSceneCount, sceneDuration, selectedLLMModel, selectedImageModel, enableVideoGeneration, selectedVideoModel, contentRating, matureCategory, intensityLevel]); |
| |
| const platformToAspectRatio = React.useCallback((platform) => { |
| if (platform === "shorts_9_16") |
| return "9:16"; |
| return "16:9"; |
| }, []); |
| |
| const computedResolution = React.useMemo(() => { |
| const aspectRatio = platformToAspectRatio(platformPreset); |
| const settings = getModelSettings(selectedImageModel || "", aspectRatio, "med"); |
| return { |
| width: settings.width, |
| height: settings.height, |
| aspectRatio, |
| aspectLabel: aspectRatio === "16:9" ? "landscape" : aspectRatio === "9:16" ? "vertical" : aspectRatio, |
| archLabel: getArchitectureLabel(settings.architecture), |
| architecture: settings.architecture, |
| }; |
| }, [platformPreset, selectedImageModel, platformToAspectRatio]); |
| const canProceedStep1 = title.trim().length > 0; |
| const canCreate = title.trim().length > 0 && !loading && generatedOutline; |
| function goTo(next) { |
| setError(null); |
| setStep(next); |
| } |
| function canNav(target) { |
| |
| |
| if (target === 5) |
| return step === 5 || (step === 4 && generatedOutline); |
| return target < step || target === step + 1; |
| } |
| |
| function handleProjectTypeSelect(type) { |
| setProjectType(type); |
| |
| if (type === "slideshow") { |
| setPlatformPreset("slides_16_9"); |
| |
| setEnableVideoGeneration(false); |
| } |
| else if (type === "video_series") { |
| setPlatformPreset("youtube_16_9"); |
| |
| setEnableVideoGeneration(true); |
| } |
| else { |
| |
| setPlatformPreset("youtube_16_9"); |
| setEnableVideoGeneration(true); |
| } |
| } |
| function toggleTone(t) { |
| setTones((prev) => { |
| const has = prev.includes(t); |
| if (has) |
| return prev.filter((x) => x !== t); |
| return [...prev, t]; |
| }); |
| } |
| function requestMature() { |
| setShowMatureModal(true); |
| setMatureConsentChecked(false); |
| } |
| function confirmMatureEnabled() { |
| if (!matureConsentChecked) |
| return; |
| setShowMatureModal(false); |
| setContentRating("mature"); |
| setAllowMature(true); |
| setLocalOnly(true); |
| } |
| function cancelMature() { |
| setShowMatureModal(false); |
| setMatureConsentChecked(false); |
| } |
| |
| async function handleGenerateOutline() { |
| if (!title.trim()) { |
| setError("Project name is required."); |
| setStep(1); |
| return; |
| } |
| setGeneratingOutline(true); |
| setError(null); |
| setGeneratedOutline(null); |
| try { |
| |
| let projectId = tempProjectId; |
| if (!projectId) { |
| const url = `${backendUrl.replace(/\/+$/, "")}/studio/videos`; |
| const payload = { |
| title: title.trim(), |
| logline: logline.trim(), |
| tags: tagsForBackend, |
| platformPreset, |
| targetDurationSec: targetSceneCount * sceneDuration, |
| contentRating, |
| policyMode: contentRating === "mature" ? "restricted" : "youtube_safe", |
| providerPolicy: { |
| allowMature: contentRating === "mature" ? !!allowMature : false, |
| allowedProviders: ["ollama"], |
| localOnly: contentRating === "mature" ? !!localOnly : true, |
| }, |
| }; |
| const res = await fetch(url, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| ...(authKey ? { "x-api-key": authKey } : {}), |
| }, |
| body: JSON.stringify(payload), |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ""); |
| throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`); |
| } |
| const data = await res.json(); |
| projectId = data.video?.id; |
| if (!projectId) { |
| throw new Error("No project ID returned from server"); |
| } |
| setTempProjectId(projectId); |
| } |
| |
| const outlineUrl = `${backendUrl.replace(/\/+$/, "")}/studio/videos/${projectId}/generate-outline`; |
| const outlinePayload = { |
| target_scenes: targetSceneCount, |
| scene_duration: sceneDuration, |
| ollama_model: selectedLLMModel || undefined, |
| }; |
| const outlineRes = await fetch(outlineUrl, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| ...(authKey ? { "x-api-key": authKey } : {}), |
| }, |
| body: JSON.stringify(outlinePayload), |
| }); |
| if (!outlineRes.ok) { |
| const text = await outlineRes.text().catch(() => ""); |
| throw new Error(`Failed to generate outline: HTTP ${outlineRes.status}${text ? `: ${text}` : ""}`); |
| } |
| const outlineData = await outlineRes.json(); |
| if (!outlineData.ok || !outlineData.outline) { |
| throw new Error("Outline generation failed - no outline returned"); |
| } |
| setGeneratedOutline(outlineData.outline); |
| setStep(5); |
| } |
| catch (e) { |
| setError(e.message || String(e)); |
| } |
| finally { |
| setGeneratingOutline(false); |
| } |
| } |
| async function handleCreate() { |
| |
| if (!tempProjectId) { |
| setError("Project not created yet. Please generate outline first."); |
| return; |
| } |
| if (!generatedOutline) { |
| setError("No outline generated. Please generate outline first."); |
| return; |
| } |
| setLoading(true); |
| setError(null); |
| try { |
| |
| onProjectCreated(tempProjectId, { |
| targetSceneCount, |
| sceneDuration, |
| llmModel: selectedLLMModel, |
| imageModel: selectedImageModel, |
| videoModel: selectedVideoModel, |
| enableVideoGeneration, |
| imageWidth: computedResolution.width, |
| imageHeight: computedResolution.height, |
| }); |
| } |
| catch (e) { |
| setError(e.message || String(e)); |
| } |
| finally { |
| setLoading(false); |
| } |
| } |
| return (<div className="min-h-screen w-full bg-[#0f0f0f] text-[#f1f1f1] flex flex-col"> |
| {/* Header with Exit Button */} |
| <div className="flex items-center justify-between px-5 py-3 border-b border-[#2a2a2a] bg-[#1a1a1a]"> |
| <button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#aaa] hover:text-[#f1f1f1] hover:bg-[#2a2a2a] rounded-lg transition-colors" onClick={onExit} title="Return to Studio"> |
| <ArrowLeft size={16}/> |
| <span>Back to Studio</span> |
| </button> |
| <span className="text-sm font-semibold text-[#f1f1f1]">Creator Studio</span> |
| <div className="w-[120px]"/> {/* Spacer for centering */} |
| </div> |
| |
| {/* Mature Consent Modal */} |
| {showMatureModal && (<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> |
| <div className="absolute inset-0 bg-black/80" onClick={cancelMature}/> |
| <div className="relative w-full max-w-md rounded-lg border border-[#3f3f3f] bg-[#1f1f1f] shadow-2xl"> |
| <div className="p-5"> |
| <div className="text-lg font-medium">Enable Mature Mode?</div> |
| <div className="mt-2 text-sm text-[#aaa] leading-relaxed"> |
| This enables adult/mature generation for this project. Use only where legal and compliant with platform policies. |
| </div> |
| |
| <div className="mt-4 rounded border border-[#3f3f3f] bg-[#121212] p-4"> |
| <div className="text-sm font-medium">This allows:</div> |
| <ul className="mt-2 text-sm text-[#aaa] space-y-1 list-disc pl-5"> |
| <li>Adult/NSFW image generation</li> |
| <li>Mature story themes</li> |
| <li>Access to mature presets (when enabled server-side)</li> |
| </ul> |
| </div> |
| |
| <label className="mt-4 flex items-start gap-3 cursor-pointer select-none"> |
| <input type="checkbox" className="mt-1 w-[18px] h-[18px] accent-[#3ea6ff]" checked={matureConsentChecked} onChange={(e) => setMatureConsentChecked(e.target.checked)}/> |
| <div className="text-sm"> |
| I am 18+ and I consent to enable Mature Mode for this project. |
| </div> |
| </label> |
| |
| <div className="mt-5 flex items-center justify-end gap-3"> |
| <button className="px-6 py-2 text-sm font-medium text-[#aaa] hover:text-[#f1f1f1] uppercase" onClick={cancelMature}> |
| Cancel |
| </button> |
| <button className="px-6 py-2 text-sm font-semibold bg-[#3ea6ff] text-black rounded-sm hover:bg-[#6ebbff] disabled:opacity-50 disabled:cursor-not-allowed uppercase" disabled={!matureConsentChecked} onClick={confirmMatureEnabled}> |
| Enable |
| </button> |
| </div> |
| </div> |
| </div> |
| </div>)} |
| |
| {/* Main Wizard Content */} |
| <div className="flex-1 flex items-center justify-center p-5"> |
| <div className="w-full max-w-[900px] bg-[#1f1f1f] rounded-lg border border-[#3f3f3f] shadow-[0_4px_20px_rgba(0,0,0,0.5)] overflow-hidden flex flex-col" style={{ height: "min(80vh, 700px)", minHeight: "600px" }}> |
| {/* Wizard Header */} |
| <div className="px-6 py-4 border-b border-[#3f3f3f] flex items-center justify-between"> |
| <div className="text-xl font-medium">New Project</div> |
| <button className="p-2 rounded-full text-[#aaa] hover:text-[#f1f1f1] hover:bg-[#3f3f3f] transition-colors" onClick={onExit} title="Close wizard"> |
| <X size={20}/> |
| </button> |
| </div> |
| |
| {/* Horizontal Stepper */} |
| <div className="flex justify-center py-5 border-b border-[#3f3f3f] bg-[#1f1f1f]"> |
| <StepNav step={step} mine={0} label="Type" canNav={canNav(0)} onClick={() => canNav(0) && goTo(0)} totalSteps={6}/> |
| <StepNav step={step} mine={1} label="Details" canNav={canNav(1)} onClick={() => canNav(1) && goTo(1)} totalSteps={6}/> |
| <StepNav step={step} mine={2} label="Visuals" canNav={canNav(2)} onClick={() => canNav(2) && goTo(2)} totalSteps={6}/> |
| <StepNav step={step} mine={3} label="Checks" canNav={canNav(3)} onClick={() => canNav(3) && goTo(3)} totalSteps={6}/> |
| <StepNav step={step} mine={4} label="Review" canNav={canNav(4)} onClick={() => canNav(4) && goTo(4)} totalSteps={6}/> |
| <StepNav step={step} mine={5} label="Outline" canNav={canNav(5)} onClick={() => canNav(5) && goTo(5)} totalSteps={6}/> |
| </div> |
| |
| {/* Content Area */} |
| <div className="flex-1 overflow-y-auto px-12 py-8"> |
| {/* STEP 0: Project Type */} |
| {step === 0 && (<div> |
| <h1 className="text-2xl font-medium">Choose Your Project Type</h1> |
| <p className="mt-2 text-sm text-[#aaa]">Select the type of content you want to create. This determines how your scenes will be generated and played back.</p> |
| |
| <div className="mt-8 grid grid-cols-1 gap-4"> |
| {/* Video Project */} |
| <ProjectTypeCard icon="Film" title="Video Project" description="Full motion AI-generated video content. Perfect for YouTube videos, documentaries, and cinematic storytelling. Scenes are animated with AI video generation." features={["AI video generation for each scene", "Cinematic motion & transitions", "Best for narrative content"]} selected={projectType === "video"} onClick={() => handleProjectTypeSelect("video")}/> |
| |
| {/* Slideshow */} |
| <ProjectTypeCard icon="Images" title="Slideshow" description="Image slideshow with Ken Burns effect and narration. Quick to generate, ideal for presentations, educational content, and photo stories." features={["Fast generation (images only)", "Ken Burns pan & zoom effects", "Best for educational/presentations"]} selected={projectType === "slideshow"} onClick={() => handleProjectTypeSelect("slideshow")}/> |
| |
| {/* Video Series */} |
| <ProjectTypeCard icon="Layers" title="Video Series" description="Multiple video clips stitched together into episodes. Great for episodic content, tutorials, and long-form video series." features={["Multiple 4-second video clips", "Episode-based structure", "Best for serialized content"]} selected={projectType === "video_series"} onClick={() => handleProjectTypeSelect("video_series")}/> |
| </div> |
| |
| <div className="mt-6 p-4 bg-[#121212] border border-[#3f3f3f] rounded"> |
| <div className="text-sm font-medium text-[#3ea6ff]">All project types include:</div> |
| <div className="mt-2 text-sm text-[#aaa] flex flex-wrap gap-x-6 gap-y-1"> |
| <span>AI Narration (TTS)</span> |
| <span>TV Mode Playback</span> |
| <span>Scene-by-Scene Editing</span> |
| <span>Export to Video</span> |
| </div> |
| </div> |
| </div>)} |
| |
| {/* STEP 1: Details */} |
| {step === 1 && (<div> |
| <h1 className="text-2xl font-medium">Details</h1> |
| <p className="mt-2 text-sm text-[#aaa]">Give your project a title and choose the format.</p> |
| |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-2">Title (required)</label> |
| <input type="text" className="w-full px-3 py-3 bg-[#121212] border border-[#3f3f3f] rounded text-base outline-none focus:border-[#3ea6ff]" placeholder="Add a title that describes your content" value={title} onChange={(e) => setTitle(e.target.value)}/> |
| </div> |
| |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-2">Description (recommended)</label> |
| <p className="text-xs text-[#777] mb-2">Describe what topic or subject this video will cover. This helps the AI generate better content.</p> |
| <textarea className="w-full px-3 py-3 bg-[#121212] border border-[#3f3f3f] rounded text-base outline-none focus:border-[#3ea6ff] resize-none" rows={3} placeholder="E.g., 'A documentary about the history of ancient Rome, focusing on the rise and fall of the empire, key emperors, and daily life of citizens.'" value={logline} onChange={(e) => setLogline(e.target.value)}/> |
| </div> |
| |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">Format</label> |
| <div className="grid grid-cols-3 gap-4"> |
| <FormatCard icon="TV" title="YouTube Video" sub="16:9 Landscape" selected={platformPreset === "youtube_16_9"} onClick={() => setPlatformPreset("youtube_16_9")}/> |
| <FormatCard icon="Phone" title="YouTube Short" sub="9:16 Vertical" selected={platformPreset === "shorts_9_16"} onClick={() => setPlatformPreset("shorts_9_16")}/> |
| <FormatCard icon="Image" title="Slides" sub="16:9 Presentation" selected={platformPreset === "slides_16_9"} onClick={() => setPlatformPreset("slides_16_9")}/> |
| </div> |
| </div> |
| |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">Intent</label> |
| <div className="flex gap-2 flex-wrap"> |
| {["Entertain", "Educate", "Inspire"].map((g) => (<Chip key={g} active={goal === g} onClick={() => setGoal(g)}> |
| {g} |
| </Chip>))} |
| </div> |
| </div> |
| |
| {/* Episode Configuration */} |
| <div className="mt-6 grid grid-cols-2 gap-4"> |
| <div> |
| <label className="block text-xs font-medium text-[#aaa] mb-2">Scenes per Episode</label> |
| <div className="flex gap-2 flex-wrap"> |
| {[4, 6, 8, 10, 12].map((count) => (<button key={count} type="button" onClick={() => setTargetSceneCount(count)} className={[ |
| "px-4 py-2 rounded text-sm border transition-colors", |
| targetSceneCount === count |
| ? "bg-[#3ea6ff] text-black border-transparent font-medium" |
| : "bg-[#282828] text-[#aaa] border-transparent hover:bg-[#3f3f3f] hover:text-[#f1f1f1]", |
| ].join(" ")}> |
| {count} |
| </button>))} |
| </div> |
| <p className="text-xs text-[#777] mt-1"> |
| ~{targetSceneCount * sceneDuration}s total ({Math.floor(targetSceneCount * sceneDuration / 60)}:{String((targetSceneCount * sceneDuration) % 60).padStart(2, '0')}) |
| </p> |
| </div> |
| <div> |
| <label className="block text-xs font-medium text-[#aaa] mb-2">Scene Duration (sec)</label> |
| <div className="flex gap-2 flex-wrap"> |
| {[3, 5, 7, 10].map((dur) => (<button key={dur} type="button" onClick={() => setSceneDuration(dur)} className={[ |
| "px-4 py-2 rounded text-sm border transition-colors", |
| sceneDuration === dur |
| ? "bg-[#3ea6ff] text-black border-transparent font-medium" |
| : "bg-[#282828] text-[#aaa] border-transparent hover:bg-[#3f3f3f] hover:text-[#f1f1f1]", |
| ].join(" ")}> |
| {dur}s |
| </button>))} |
| </div> |
| </div> |
| </div> |
| </div>)} |
| |
| {/* STEP 2: Visuals */} |
| {step === 2 && (<div> |
| <h1 className="text-2xl font-medium">Visual Elements</h1> |
| <p className="mt-2 text-sm text-[#aaa]">Choose the aesthetic for your generated content.</p> |
| |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">Style Preset</label> |
| <div className="grid grid-cols-3 gap-4"> |
| <FormatCard icon="Camera" title="Cinematic" sub="High fidelity realism" selected={visualStyle === "Cinematic"} onClick={() => setVisualStyle("Cinematic")}/> |
| <FormatCard icon="Palette" title="Digital Art" sub="Stylized illustration" selected={visualStyle === "Digital Art"} onClick={() => setVisualStyle("Digital Art")}/> |
| <FormatCard icon="Star" title="Anime" sub="Japanese animation style" selected={visualStyle === "Anime"} onClick={() => setVisualStyle("Anime")}/> |
| </div> |
| </div> |
| |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">Mood & Tone</label> |
| <div className="flex gap-2 flex-wrap"> |
| {["Documentary", "Dramatic", "Calm", "Upbeat", "Dark"].map((t) => (<Chip key={t} active={tones.includes(t)} onClick={() => toggleTone(t)}> |
| {t} |
| </Chip>))} |
| </div> |
| </div> |
| |
| {/* LLM Model Selection */} |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">AI Story Model</label> |
| <p className="text-xs text-[#777] mb-3">Select the AI model for generating outlines and narration</p> |
| <div className="relative"> |
| <select value={selectedLLMModel} onChange={(e) => setSelectedLLMModel(e.target.value)} className="w-full px-4 py-3 bg-[#121212] border border-[#3f3f3f] rounded text-base outline-none focus:border-[#3ea6ff] appearance-none cursor-pointer" disabled={loadingModels}> |
| {loadingModels ? (<option value="">Loading models...</option>) : availableLLMModels.length === 0 ? (<option value="">No models available</option>) : (availableLLMModels.map((m) => (<option key={m.id} value={m.id}> |
| {m.name} |
| </option>)))} |
| </select> |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#aaa] pointer-events-none"/> |
| </div> |
| {availableLLMModels.length === 0 && !loadingModels && (<p className="text-xs text-[#ff6b6b] mt-2"> |
| No Ollama models found. Make sure Ollama is running with at least one model pulled. |
| </p>)} |
| </div> |
| |
| {/* Image Model Selection */} |
| <div className="mt-6"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">Image Generation Model</label> |
| <p className="text-xs text-[#777] mb-3">Select the model for generating scene images</p> |
| <div className="relative"> |
| <select value={selectedImageModel} onChange={(e) => setSelectedImageModel(e.target.value)} className="w-full px-4 py-3 bg-[#121212] border border-[#3f3f3f] rounded text-base outline-none focus:border-[#3ea6ff] appearance-none cursor-pointer" disabled={loadingImageModels}> |
| {loadingImageModels ? (<option value="">Loading models...</option>) : availableImageModels.length === 0 ? (<option value="">No models available</option>) : (availableImageModels.map((m) => (<option key={m.id} value={m.id}> |
| {m.name} |
| </option>)))} |
| </select> |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#aaa] pointer-events-none"/> |
| </div> |
| {/* Architecture & Resolution indicator */} |
| {selectedImageModel && (<div className="mt-3 p-4 bg-gradient-to-r from-[#3ea6ff]/10 to-[#8B5CF6]/10 border border-[#3ea6ff]/30 rounded-lg"> |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-xs font-medium text-[#3ea6ff]">📐 Output Resolution</span> |
| <span className="text-xs text-[#aaa] px-2 py-0.5 bg-[#282828] rounded"> |
| {computedResolution.archLabel} |
| {computedResolution.architecture === "flux_schnell" && " (Turbo)"} |
| </span> |
| </div> |
| <div className="flex items-center gap-4"> |
| <span className="text-xl font-mono font-bold text-white"> |
| {computedResolution.width} × {computedResolution.height} |
| </span> |
| <span className="text-xs text-[#aaa] px-2 py-1 bg-[#282828] rounded"> |
| {computedResolution.aspectLabel} |
| </span> |
| </div> |
| <div className="mt-2 text-xs text-[#666]"> |
| Based on {computedResolution.archLabel} + {platformPresetToLabel(platformPreset)} |
| </div> |
| {computedResolution.architecture === "sd15" && (<div className="mt-2 text-xs text-[#f59e0b] bg-[#f59e0b]/10 border border-[#f59e0b]/20 rounded px-2 py-1"> |
| ⚠️ SD 1.5 uses lower resolution to prevent duplicate subjects |
| </div>)} |
| </div>)} |
| {availableImageModels.length === 0 && !loadingImageModels && (<p className="text-xs text-[#ff6b6b] mt-2"> |
| No image models found. Make sure ComfyUI has checkpoints in its models folder. |
| </p>)} |
| </div> |
| |
| {/* Video Generation Toggle + Model Selection */} |
| <div className="mt-6"> |
| <div className="flex items-center justify-between mb-3"> |
| <div> |
| <label className="block text-xs font-medium text-[#aaa]">Video Generation</label> |
| <p className="text-xs text-[#777] mt-1">Generate AI video clips for each scene after images</p> |
| </div> |
| <button type="button" onClick={() => setEnableVideoGeneration(!enableVideoGeneration)} className={[ |
| "relative w-12 h-6 rounded-full transition-colors", |
| enableVideoGeneration ? "bg-[#3ea6ff]" : "bg-[#3f3f3f]" |
| ].join(" ")}> |
| <div className={[ |
| "absolute top-1 w-4 h-4 rounded-full bg-white transition-transform", |
| enableVideoGeneration ? "translate-x-7" : "translate-x-1" |
| ].join(" ")}/> |
| </button> |
| </div> |
| |
| {/* Show project type hint */} |
| {projectType === "slideshow" && enableVideoGeneration && (<div className="mb-3 p-2 bg-[#f59e0b]/10 border border-[#f59e0b]/30 rounded text-xs text-[#f59e0b]"> |
| Note: Slideshow projects typically use images with Ken Burns effect. Video generation will create animated clips instead. |
| </div>)} |
| |
| {enableVideoGeneration && (<> |
| <div className="relative"> |
| <select value={selectedVideoModel} onChange={(e) => setSelectedVideoModel(e.target.value)} className="w-full px-4 py-3 bg-[#121212] border border-[#3f3f3f] rounded text-base outline-none focus:border-[#3ea6ff] appearance-none cursor-pointer" disabled={loadingVideoModels}> |
| {loadingVideoModels ? (<option value="">Loading models...</option>) : availableVideoModels.length === 0 ? (<option value="">No models available</option>) : (availableVideoModels.map((m) => (<option key={m.id} value={m.id}> |
| {m.name} |
| </option>)))} |
| </select> |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#aaa] pointer-events-none"/> |
| </div> |
| {availableVideoModels.length === 0 && !loadingVideoModels && (<p className="text-xs text-[#ff6b6b] mt-2"> |
| No video models found. Make sure ComfyUI has video models installed (e.g., Wan2.1, LTX-Video). |
| </p>)} |
| {selectedVideoModel && (<div className="mt-2 p-3 bg-[#121212] border border-[#3f3f3f] rounded text-xs text-[#aaa]"> |
| <div className="flex items-center gap-2 text-[#3ea6ff]"> |
| <span className="font-medium">Generation Flow:</span> |
| </div> |
| <div className="mt-2 flex items-center gap-2"> |
| <span className="px-2 py-1 bg-[#282828] rounded">1. Create Scenes</span> |
| <span className="text-[#666]">→</span> |
| <span className="px-2 py-1 bg-[#282828] rounded">2. Generate Images</span> |
| <span className="text-[#666]">→</span> |
| <span className="px-2 py-1 bg-[#3ea6ff]/20 text-[#3ea6ff] rounded">3. Generate Videos</span> |
| </div> |
| </div>)} |
| </>)} |
| |
| {!enableVideoGeneration && (<div className="p-3 bg-[#121212] border border-[#3f3f3f] rounded text-xs text-[#aaa]"> |
| <div className="flex items-center gap-2 text-[#888]"> |
| <span className="font-medium">Generation Flow (Images Only):</span> |
| </div> |
| <div className="mt-2 flex items-center gap-2"> |
| <span className="px-2 py-1 bg-[#282828] rounded">1. Create Scenes</span> |
| <span className="text-[#666]">→</span> |
| <span className="px-2 py-1 bg-[#282828] rounded">2. Generate Images</span> |
| <span className="text-[#666]">→</span> |
| <span className="px-2 py-1 bg-[#282828] rounded text-[#666]">Ken Burns Effect</span> |
| </div> |
| </div>)} |
| </div> |
| </div>)} |
| |
| {/* STEP 3: Checks */} |
| {step === 3 && (<div> |
| <h1 className="text-2xl font-medium">Checks</h1> |
| <p className="mt-2 text-sm text-[#aaa]">We'll check your project for consistency and policy compliance.</p> |
| |
| <div className="mt-6 bg-[#282828] border border-[#3f3f3f] rounded"> |
| <CheckRow checked={lockIdentity} title="Consistency Lock" desc="Keep characters and environments stable across scenes." onToggle={() => setLockIdentity((v) => !v)}/> |
| <CheckRow checked={contentRating === "sfw"} title="Policy Filter (SFW)" desc="Strictly filter explicit content. Uncheck for Mature mode (18+)." onToggle={() => { |
| if (contentRating === "sfw") { |
| requestMature(); |
| } |
| else { |
| setContentRating("sfw"); |
| setAllowMature(false); |
| } |
| }} last/> |
| </div> |
| |
| {contentRating === "mature" && (<div className="mt-6 p-5 bg-gradient-to-br from-[#8B5CF6]/10 to-[#EC4899]/10 border border-[#8B5CF6]/30 rounded-lg"> |
| <div className="flex items-center gap-2 mb-4"> |
| <div className="w-8 h-8 rounded-lg bg-[#8B5CF6]/20 flex items-center justify-center"> |
| <span className="text-lg">🔞</span> |
| </div> |
| <div> |
| <div className="font-medium text-[#A78BFA]">Mature Mode Settings</div> |
| <div className="text-xs text-[#aaa]">Configure adult content preferences for this project</div> |
| </div> |
| </div> |
| |
| {/* Content Category */} |
| <div className="mb-5"> |
| <label className="block text-xs font-medium text-[#aaa] mb-3">Content Style</label> |
| <div className="grid grid-cols-2 gap-2"> |
| <MatureCategoryCard icon="💋" title="Fan Service" desc="Suggestive poses, revealing outfits" selected={matureCategory === "fan_service"} onClick={() => setMatureCategory("fan_service")}/> |
| <MatureCategoryCard icon="💕" title="Romantic" desc="Intimate moments, passion" selected={matureCategory === "romantic"} onClick={() => setMatureCategory("romantic")}/> |
| <MatureCategoryCard icon="🔥" title="Sensual" desc="Artistic nudity, sensuality" selected={matureCategory === "sensual"} onClick={() => setMatureCategory("sensual")}/> |
| <MatureCategoryCard icon="⚡" title="Explicit" desc="Full adult content" selected={matureCategory === "explicit"} onClick={() => setMatureCategory("explicit")}/> |
| </div> |
| </div> |
| |
| {/* Intensity Level - Professional Slider */} |
| <div className="mb-4 p-4 rounded-xl bg-[#EC4899]/5 border border-[#EC4899]/20"> |
| <div className="flex justify-between items-center mb-3"> |
| <span className="text-xs text-[#EC4899] font-semibold">Intensity Strength</span> |
| <span className="text-xs text-[#aaa] font-mono">{intensityLevel.toFixed(2)}</span> |
| </div> |
| <input type="range" min="0" max="1" step="0.05" value={intensityLevel} onChange={(e) => setIntensityLevel(parseFloat(e.target.value))} className="w-full h-2 bg-[#EC4899]/20 rounded-full appearance-none cursor-pointer accent-[#EC4899]"/> |
| <div className="flex justify-between text-[10px] text-[#666] mt-2"> |
| <span>Tasteful</span> |
| <span>Bold</span> |
| </div> |
| <div className="mt-3 text-xs text-[#777]"> |
| {intensityLevel < 0.25 && "Suggestive themes, light teasing, clothed scenes"} |
| {intensityLevel >= 0.25 && intensityLevel < 0.5 && "Partial nudity, intimate moments, sensual content"} |
| {intensityLevel >= 0.5 && intensityLevel < 0.75 && "Full nudity, explicit scenes, adult themes"} |
| {intensityLevel >= 0.75 && "Uncensored adult content, maximum intensity"} |
| </div> |
| </div> |
| |
| {/* Privacy Notice */} |
| <div className="p-3 bg-black/20 rounded text-xs text-[#888] flex items-start gap-2"> |
| <span>🔒</span> |
| <span>Content generated locally. Not uploaded to cloud services. You are responsible for compliance with local laws.</span> |
| </div> |
| </div>)} |
| |
| <div className="mt-5 text-sm text-[#aaa] flex items-center gap-2"> |
| <span className="text-[#2ba640]">OK</span> Checks complete. No issues found. |
| </div> |
| </div>)} |
| |
| {/* STEP 4: Review */} |
| {step === 4 && (<div> |
| <h1 className="text-2xl font-medium">Review</h1> |
| <p className="mt-2 text-sm text-[#aaa]">Review your settings, then generate your story outline.</p> |
| |
| <div className="mt-6 bg-[#121212] border border-[#3f3f3f] rounded p-5"> |
| <ReviewLine label="Project Type" value={projectTypeToLabel(projectType)}/> |
| <ReviewLine label="Title" value={title.trim() || "Untitled Project"}/> |
| <ReviewLine label="Format" value={platformPresetToLabel(platformPreset)}/> |
| <ReviewLine label="Style" value={`${visualStyle} (${tones.length ? tones.join(", ") : "Default"})`}/> |
| <ReviewLine label="Episode Length" value={`${targetSceneCount} scenes × ${sceneDuration}s = ~${Math.floor(targetSceneCount * sceneDuration / 60)}:${String((targetSceneCount * sceneDuration) % 60).padStart(2, '0')}`}/> |
| <ReviewLine label="Story AI" value={selectedLLMModel || "Default"}/> |
| <ReviewLine label="Image Model" value={selectedImageModel ? `${selectedImageModel} (${getArchitectureLabel(detectArchitecture(selectedImageModel))})` : "Default"}/> |
| <ReviewLine label="Output Resolution" value={`${computedResolution.width} × ${computedResolution.height} (${computedResolution.aspectRatio})`} color="text-[#3ea6ff]"/> |
| <ReviewLine label="Video Generation" value={enableVideoGeneration ? (selectedVideoModel || "Enabled (auto-select)") : "Disabled (images only)"} color={enableVideoGeneration ? "text-[#3ea6ff]" : "text-[#888]"}/> |
| <ReviewLine label="Content Rating" value={contentRating === "sfw" ? "Safe (SFW)" : "Mature (18+)"} color={contentRating === "sfw" ? "text-[#2ba640]" : "text-[#8B5CF6]"} last={contentRating === "sfw"}/> |
| {contentRating === "mature" && (<> |
| <ReviewLine label="Content Style" value={matureCategoryToLabel(matureCategory)} color="text-[#EC4899]"/> |
| <ReviewLine label="Intensity" value={`${intensityLevel.toFixed(2)} (${intensityLevel < 0.25 ? 'Tasteful' : intensityLevel < 0.5 ? 'Moderate' : intensityLevel < 0.75 ? 'Bold' : 'Maximum'})`} color="text-[#EC4899]" last/> |
| </>)} |
| </div> |
| |
| {error && (<div className="mt-4 p-4 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-300"> |
| {error} |
| </div>)} |
| |
| {/* Show description summary if provided */} |
| {logline.trim() && (<div className="mt-6 bg-[#121212] border border-[#3f3f3f] rounded p-4"> |
| <div className="text-xs font-medium text-[#aaa] mb-2">Project Description</div> |
| <div className="text-sm text-[#f1f1f1] leading-relaxed">{logline}</div> |
| <button type="button" onClick={() => goTo(1)} className="mt-2 text-xs text-[#3ea6ff] hover:underline"> |
| Edit in Details step |
| </button> |
| </div>)} |
| |
| <div className="mt-4 text-sm"> |
| <div className="text-[#f1f1f1]">Private project</div> |
| <div className="text-[#aaa]">Only you can view and edit this project initially.</div> |
| </div> |
| </div>)} |
| |
| {/* STEP 5: Outline Generation & Review */} |
| {step === 5 && (<div> |
| <h1 className="text-2xl font-medium">Story Outline</h1> |
| <p className="mt-2 text-sm text-[#aaa]"> |
| {generatingOutline |
| ? "Generating your story outline with AI..." |
| : "Review your AI-generated story outline before creating the project."} |
| </p> |
| |
| {/* Generation Loading State */} |
| {generatingOutline && (<div className="mt-8 flex flex-col items-center justify-center py-12"> |
| <div className="w-16 h-16 border-4 border-[#3ea6ff] border-t-transparent rounded-full animate-spin"/> |
| <p className="mt-4 text-[#aaa]">Generating {targetSceneCount} scenes...</p> |
| <p className="mt-2 text-xs text-[#666]">This may take a minute depending on your AI model</p> |
| </div>)} |
| |
| {/* Error State */} |
| {error && !generatingOutline && (<div className="mt-4 p-4 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-300"> |
| {error} |
| <button className="ml-4 underline hover:text-red-200" onClick={() => { |
| setError(null); |
| handleGenerateOutline(); |
| }}> |
| Try Again |
| </button> |
| </div>)} |
| |
| {/* Generated Outline Display */} |
| {generatedOutline && !generatingOutline && (<div className="mt-6 space-y-4"> |
| {/* Story Arc Summary */} |
| {generatedOutline.story_arc && (<div className="bg-[#121212] border border-[#3f3f3f] rounded p-4"> |
| <div className="text-sm font-medium text-[#3ea6ff] mb-2">Story Arc</div> |
| <div className="text-xs text-[#aaa] space-y-1"> |
| <div><span className="text-[#f1f1f1]">Beginning:</span> {generatedOutline.story_arc.beginning}</div> |
| <div><span className="text-[#f1f1f1]">Rising Action:</span> {generatedOutline.story_arc.rising_action}</div> |
| <div><span className="text-[#f1f1f1]">Climax:</span> {generatedOutline.story_arc.climax}</div> |
| <div><span className="text-[#f1f1f1]">Resolution:</span> {generatedOutline.story_arc.resolution}</div> |
| </div> |
| </div>)} |
| |
| {/* Scenes List */} |
| <div className="bg-[#121212] border border-[#3f3f3f] rounded overflow-hidden"> |
| <div className="px-4 py-3 border-b border-[#3f3f3f] flex items-center justify-between"> |
| <span className="text-sm font-medium">Scenes ({generatedOutline.scenes?.length || 0})</span> |
| <span className="text-xs text-[#aaa]">Click a scene to preview</span> |
| </div> |
| <div className="max-h-[300px] overflow-y-auto"> |
| {generatedOutline.scenes?.map((scene, idx) => (<div key={idx} className="px-4 py-3 border-b border-[#2a2a2a] last:border-b-0 hover:bg-[#1a1a1a] transition-colors"> |
| <div className="flex items-start gap-3"> |
| <div className="w-8 h-8 rounded-full bg-[#3ea6ff]/20 text-[#3ea6ff] flex items-center justify-center text-sm font-medium flex-shrink-0"> |
| {scene.scene_number || idx + 1} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <div className="text-sm font-medium text-[#f1f1f1]">{scene.title}</div> |
| <div className="text-xs text-[#aaa] mt-1 line-clamp-2">{scene.narration}</div> |
| <details className="mt-2"> |
| <summary className="text-xs text-[#666] cursor-pointer hover:text-[#aaa]"> |
| View image prompt |
| </summary> |
| <div className="mt-2 p-2 bg-[#0a0a0a] rounded text-xs text-[#888] leading-relaxed"> |
| {scene.image_prompt} |
| </div> |
| </details> |
| </div> |
| </div> |
| </div>))} |
| </div> |
| </div> |
| |
| {/* Regenerate Option */} |
| <div className="flex items-center justify-between text-sm"> |
| <span className="text-[#aaa]">Not happy with the outline?</span> |
| <button className="text-[#3ea6ff] hover:underline" onClick={() => { |
| setGeneratedOutline(null); |
| handleGenerateOutline(); |
| }} disabled={generatingOutline}> |
| Regenerate Outline |
| </button> |
| </div> |
| </div>)} |
| </div>)} |
| </div> |
| |
| {/* Footer */} |
| <div className="px-6 py-4 border-t border-[#3f3f3f] flex justify-end gap-3"> |
| {step > 0 && step !== 5 && (<button className="px-6 py-2 text-sm font-medium text-[#aaa] hover:text-[#f1f1f1] uppercase" onClick={() => goTo((step - 1))} disabled={loading || generatingOutline}> |
| Back |
| </button>)} |
| {step === 5 && !generatingOutline && (<button className="px-6 py-2 text-sm font-medium text-[#aaa] hover:text-[#f1f1f1] uppercase" onClick={() => goTo(4)} disabled={loading}> |
| Back to Review |
| </button>)} |
| {step < 4 ? (<button className="px-6 py-2 text-sm font-semibold bg-[#3ea6ff] text-black rounded-sm hover:bg-[#6ebbff] disabled:opacity-50 disabled:cursor-not-allowed uppercase" disabled={step === 1 && !canProceedStep1} onClick={() => goTo((step + 1))}> |
| Next |
| </button>) : step === 4 ? (<button className="px-6 py-2 text-sm font-semibold bg-[#3ea6ff] text-black rounded-sm hover:bg-[#6ebbff] disabled:opacity-50 disabled:cursor-not-allowed uppercase" disabled={generatingOutline || !title.trim()} onClick={handleGenerateOutline}> |
| {generatingOutline ? "Generating..." : "Generate Outline"} |
| </button>) : (<button className="px-6 py-2 text-sm font-semibold bg-[#3ea6ff] text-black rounded-sm hover:bg-[#6ebbff] disabled:opacity-50 disabled:cursor-not-allowed uppercase" disabled={!canCreate || generatingOutline} onClick={handleCreate}> |
| {loading ? "Creating..." : "Create Project"} |
| </button>)} |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {loading && (<div className="fixed bottom-20 left-1/2 -translate-x-1/2 bg-[#333] text-white px-6 py-3 rounded text-sm shadow-lg"> |
| Creating Project... |
| </div>)} |
| </div>); |
| } |
| |
| function StepNav({ step, mine, label, canNav, onClick, totalSteps = 6, }) { |
| const isActive = step === mine; |
| const isDone = step > mine; |
| |
| const isLastStep = mine === totalSteps - 1; |
| return (<button type="button" onClick={onClick} className={[ |
| "flex flex-col items-center w-[100px] relative", |
| canNav ? "cursor-pointer" : "cursor-default", |
| ].join(" ")}> |
| {/* Connector line */} |
| {!isLastStep && (<div className={[ |
| "absolute top-3 left-1/2 w-full h-0.5 z-0", |
| isDone ? "bg-[#3ea6ff]" : "bg-[#3f3f3f]", |
| ].join(" ")}/>)} |
| |
| {/* Circle */} |
| <div className={[ |
| "w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold mb-2 z-10 transition-all", |
| isActive |
| ? "bg-[#3ea6ff] text-white scale-110" |
| : isDone |
| ? "bg-[#3f3f3f] text-[#3ea6ff]" |
| : "bg-[#3f3f3f] text-[#aaa]", |
| ].join(" ")}> |
| {mine + 1} |
| </div> |
| |
| {/* Label */} |
| <div className={[ |
| "text-xs font-medium uppercase tracking-wide", |
| isActive ? "text-[#f1f1f1]" : "text-[#aaa]", |
| ].join(" ")}> |
| {label} |
| </div> |
| </button>); |
| } |
| |
| function ProjectTypeCard({ icon, title, description, features, selected, onClick, }) { |
| const iconMap = { |
| Film: "\uD83C\uDFAC", |
| Images: "\uD83D\uDDBC\uFE0F", |
| Layers: "\uD83C\uDFAC", |
| }; |
| return (<button type="button" onClick={onClick} className={[ |
| "bg-[#1f1f1f] border rounded-lg p-5 text-left transition-all flex gap-5", |
| selected |
| ? "border-[#3ea6ff] bg-[rgba(62,166,255,0.08)] ring-1 ring-[#3ea6ff]" |
| : "border-[#3f3f3f] hover:bg-[#282828] hover:border-[#555]", |
| ].join(" ")}> |
| {/* Icon */} |
| <div className={[ |
| "w-14 h-14 rounded-lg flex items-center justify-center text-2xl flex-shrink-0", |
| selected ? "bg-[#3ea6ff]/20" : "bg-[#282828]" |
| ].join(" ")}> |
| {iconMap[icon] || icon} |
| </div> |
| |
| {/* Content */} |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center gap-3"> |
| <div className={[ |
| "text-lg font-medium", |
| selected ? "text-[#3ea6ff]" : "text-[#f1f1f1]" |
| ].join(" ")}> |
| {title} |
| </div> |
| {selected && (<div className="px-2 py-0.5 bg-[#3ea6ff] text-black text-xs font-medium rounded"> |
| Selected |
| </div>)} |
| </div> |
| <div className="text-sm text-[#aaa] mt-1 leading-relaxed">{description}</div> |
| <div className="flex flex-wrap gap-2 mt-3"> |
| {features.map((f, i) => (<span key={i} className="px-2 py-1 bg-[#282828] text-xs text-[#888] rounded"> |
| {f} |
| </span>))} |
| </div> |
| </div> |
| |
| {/* Selection indicator */} |
| <div className="flex-shrink-0 flex items-center"> |
| <div className={[ |
| "w-5 h-5 rounded-full border-2 flex items-center justify-center", |
| selected ? "border-[#3ea6ff] bg-[#3ea6ff]" : "border-[#555]" |
| ].join(" ")}> |
| {selected && (<svg className="w-3 h-3 text-black" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7"/> |
| </svg>)} |
| </div> |
| </div> |
| </button>); |
| } |
| |
| function FormatCard({ icon, title, sub, selected, onClick, }) { |
| const iconMap = { |
| TV: "TV", |
| Phone: "Phone", |
| Image: "Img", |
| Camera: "Cam", |
| Palette: "Art", |
| Star: "Ani", |
| }; |
| return (<button type="button" onClick={onClick} className={[ |
| "bg-[#1f1f1f] border rounded p-5 text-center transition-colors flex flex-col items-center gap-2", |
| selected |
| ? "border-[#3ea6ff] bg-[rgba(62,166,255,0.08)]" |
| : "border-[#3f3f3f] hover:bg-[#282828]", |
| ].join(" ")}> |
| <div className="text-2xl">{iconMap[icon] || icon}</div> |
| <div className="text-[15px] font-medium">{title}</div> |
| <div className="text-xs text-[#aaa]">{sub}</div> |
| </button>); |
| } |
| |
| function Chip({ active, children, onClick, }) { |
| return (<button type="button" onClick={onClick} className={[ |
| "px-4 py-2 rounded-full text-sm border transition-colors", |
| active |
| ? "bg-[#f1f1f1] text-[#0f0f0f] font-medium border-transparent" |
| : "bg-[#282828] text-[#aaa] border-transparent hover:bg-[#3f3f3f] hover:text-[#f1f1f1]", |
| ].join(" ")}> |
| {children} |
| </button>); |
| } |
| |
| function CheckRow({ checked, title, desc, onToggle, last, }) { |
| return (<div className={["flex items-start gap-3 p-4", !last && "border-b border-[#3f3f3f]"].filter(Boolean).join(" ")}> |
| <input type="checkbox" className="w-[18px] h-[18px] accent-[#3ea6ff] mt-0.5" checked={checked} onChange={onToggle}/> |
| <div> |
| <div className="text-sm font-medium">{title}</div> |
| <div className="text-xs text-[#aaa] mt-1">{desc}</div> |
| </div> |
| </div>); |
| } |
| |
| function ReviewLine({ label, value, color, last, }) { |
| return (<div className={["flex justify-between py-3", !last && "border-b border-[#3f3f3f]"].filter(Boolean).join(" ")}> |
| <span className="text-[#aaa]">{label}</span> |
| <span className={["font-medium", color || "text-[#f1f1f1]"].join(" ")}>{value}</span> |
| </div>); |
| } |
| function platformPresetToLabel(p) { |
| if (p === "youtube_16_9") |
| return "YouTube Video (16:9)"; |
| if (p === "shorts_9_16") |
| return "YouTube Short (9:16)"; |
| return "Slides (16:9)"; |
| } |
| function projectTypeToLabel(t) { |
| if (t === "video") |
| return "Video Project"; |
| if (t === "slideshow") |
| return "Slideshow"; |
| return "Video Series"; |
| } |
| function matureCategoryToLabel(c) { |
| if (c === "fan_service") |
| return "Fan Service"; |
| if (c === "romantic") |
| return "Romantic"; |
| if (c === "sensual") |
| return "Sensual"; |
| return "Explicit"; |
| } |
| |
| function MatureCategoryCard({ icon, title, desc, selected, onClick, }) { |
| return (<button type="button" onClick={onClick} className={[ |
| "p-3 rounded-lg text-left transition-all flex items-start gap-3", |
| selected |
| ? "bg-[#8B5CF6]/20 border border-[#8B5CF6]/50 ring-1 ring-[#8B5CF6]/30" |
| : "bg-[#1f1f1f] border border-[#3f3f3f] hover:bg-[#282828] hover:border-[#555]", |
| ].join(" ")}> |
| <span className="text-xl">{icon}</span> |
| <div className="flex-1 min-w-0"> |
| <div className={[ |
| "text-sm font-medium", |
| selected ? "text-[#A78BFA]" : "text-[#f1f1f1]" |
| ].join(" ")}> |
| {title} |
| </div> |
| <div className="text-xs text-[#777] mt-0.5">{desc}</div> |
| </div> |
| {selected && (<div className="w-4 h-4 rounded-full bg-[#8B5CF6] flex items-center justify-center flex-shrink-0"> |
| <svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7"/> |
| </svg> |
| </div>)} |
| </button>); |
| } |
| export default CreatorStudioHost; |
|
|