Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 8,731 Bytes
7c600d0 ac490ae 7c600d0 ac490ae 5d1a7ff 069d37b 7c600d0 069d37b ac490ae 069d37b ac490ae 069d37b 235b830 ac490ae 235b830 ac490ae 235b830 069d37b 4f6be65 7c600d0 235b830 4f6be65 7c600d0 7701715 7c600d0 069d37b 235b830 4f6be65 7c600d0 235b830 4f6be65 7c600d0 7701715 7c600d0 43c6b76 7c600d0 43c6b76 7c600d0 4f6be65 7c600d0 069d37b 4f6be65 7c600d0 6374ef1 43c6b76 7701715 6374ef1 7c600d0 43c6b76 7c600d0 43c6b76 7c600d0 4f6be65 7c600d0 43c6b76 6374ef1 43c6b76 7c600d0 43c6b76 7c600d0 ac490ae 235b830 ac490ae 7c600d0 43c6b76 7c600d0 43c6b76 7c600d0 43c6b76 7c600d0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | import { useEffect, useMemo, useState } from "react";
import type { Project, Scene } from "../api/client";
import { generateSceneImage } from "../api/client";
import { formatAiEditCreditsDisplay } from "../lib/formatAiEditCredits";
// AI image generation costs this many AI-edit credits per image (all plans).
// Kept in sync with the backend GENERATE_IMAGE_CREDIT_COST.
export const AI_IMAGE_CREDIT_COST = 3;
export interface GenerateSceneImageModalProps {
open: boolean;
onClose: () => void;
scene: Scene;
project: Project;
/**
* Whether the owner has enough AI-edit budget for one generation (monthly
* allowance + purchased pool).
*/
canGenerate: boolean;
/** Credits one generation costs (shown in the modal for every plan). */
creditCost: number;
/**
* True when the viewer is a collaborator on a shared project (spending the OWNER's
* credit pool). They can't fix an exhausted pool by upgrading their own plan.
*/
isCollaborator?: boolean;
/** Total AI-edit budget available (allowance + purchased pool). */
creditsRemaining: number;
/** True when the blocker is the OWNER's exhausted access, not the viewer's own. */
ownerBlocked?: boolean;
onUpgrade: () => void;
/** Navigate to the billing page from the inline "Upgrade now" link. */
onUpgradeNow: () => void;
/** Shown instead of the self-upgrade prompt when `ownerBlocked`. */
onOwnerBlocked?: () => void;
onImageReady: (imageBase64: string, refinedPrompt: string) => void;
onGenerateStart?: () => void;
onGenerateError?: (message: string) => void;
}
export default function GenerateSceneImageModal({
open,
onClose,
scene,
project,
canGenerate,
creditCost,
isCollaborator = false,
creditsRemaining,
ownerBlocked,
onUpgrade,
onUpgradeNow,
onOwnerBlocked,
onImageReady,
onGenerateStart,
onGenerateError,
}: GenerateSceneImageModalProps) {
const [imageDescription, setImageDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const placeholderHint = useMemo(() => {
const v = (scene.visual_description || "").trim();
if (!v) return "Describe the image you want (subject, style, mood, colors)…";
const first = v.split("\n").find((line) => line.trim())?.trim() ?? v;
return first.length > 120 ? `${first.slice(0, 120)}…` : first;
}, [scene.visual_description]);
useEffect(() => {
if (!open) return;
setImageDescription("");
setError(null);
setGenerating(false);
}, [open, scene.id]);
if (!open) return null;
// A collaborator blocked by the owner's free plan can't act on an upgrade prompt.
const promptForAccess = () => {
if (ownerBlocked) onOwnerBlocked?.();
else onUpgrade();
};
const handleGenerate = async () => {
const desc = imageDescription.trim();
if (desc.length < 3) {
setError("Image description must be at least 3 characters.");
return;
}
if (!canGenerate) {
promptForAccess();
return;
}
// Keep the modal open and show a generating state so the user sees progress and
// any error, instead of the modal vanishing with no feedback.
setError(null);
setGenerating(true);
onGenerateStart?.();
try {
const res = await generateSceneImage(project.id, scene.id, {
image_description: desc,
});
// Hand the result to the parent (which shows the keep/discard preview) and close.
onImageReady(res.data.image_base64, res.data.refined_prompt);
onClose();
} catch (err: unknown) {
const status =
err && typeof err === "object" && "response" in err
? (err as { response?: { status?: number } }).response?.status
: 0;
if (status === 403) {
promptForAccess();
return;
}
const msg =
err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: "Image generation failed";
setError(String(msg || "Image generation failed"));
onGenerateError?.(String(msg || "Image generation failed"));
} finally {
setGenerating(false);
}
};
return (
<div className="fixed inset-0 z-[105] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => { if (!generating) onClose(); }}
aria-hidden
/>
<div
className="relative bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="generate-scene-image-title"
>
<div className="p-4 border-b border-gray-200 flex-shrink-0">
<h3 id="generate-scene-image-title" className="text-lg font-semibold text-gray-900">
Generate image with AI
</h3>
<p className="text-xs text-gray-500 mt-1">
Describe the image you want. Your description is the main input.
</p>
<p className="text-xs text-gray-400 mt-1">
Costs{" "}
<span className="font-medium text-gray-600">
{creditCost} AI edit credits
</span>
{isCollaborator ? (
<>
{" "}
from the project owner's balance (
{formatAiEditCreditsDisplay(creditsRemaining)} remaining).
</>
) : (
<>
{" "}
· {formatAiEditCreditsDisplay(creditsRemaining)} remaining
{!canGenerate && (
<>
{" "}
·{" "}
<button
type="button"
onClick={onUpgradeNow}
className="text-purple-600 hover:text-purple-700 underline font-medium"
>
Upgrade for more
</button>
</>
)}
</>
)}
</p>
</div>
{generating ? (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-10 text-center min-h-0">
<div className="w-12 h-12 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin" />
<div>
<p className="text-sm font-medium text-gray-800">Generating image…</p>
<p className="mt-1 text-xs text-gray-500">
This can take up to a minute. Please keep this open.
</p>
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
<div>
<label
htmlFor="scene-image-description"
className="block text-[11px] font-medium text-gray-500 uppercase tracking-wider mb-1.5"
>
Image description <span className="text-red-500">*</span>
</label>
<textarea
id="scene-image-description"
value={imageDescription}
onChange={(e) => setImageDescription(e.target.value)}
placeholder={placeholderHint}
rows={4}
autoFocus
className="w-full px-3 py-2 text-sm text-gray-800 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 resize-y leading-relaxed"
/>
<p className="text-xs text-gray-500 mt-2">
The scene description will also be incorporated into your prompt to generate the
image.
</p>
</div>
{error ? (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
) : null}
</div>
)}
{!generating && (
<div className="p-4 border-t border-gray-200 flex gap-2 flex-shrink-0">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="button"
onClick={handleGenerate}
disabled={imageDescription.trim().length < 3}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Generate
</button>
</div>
)}
</div>
</div>
);
}
|