titusgiap commited on
Commit
c04ebb3
·
2 Parent(s): 30b7d3aa26bdd6

Merge branch 'main' of https://github.com/Digital-Transformation-TNT/website_content_tnt

Browse files
.gitignore CHANGED
@@ -3,3 +3,4 @@ frontend/dist/
3
  __pycache__/
4
  *.pyc
5
  .env
 
 
3
  __pycache__/
4
  *.pyc
5
  .env
6
+ server_*.log
backend/app/api/flux2.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from fastapi import APIRouter, HTTPException
12
+ from fastapi.responses import FileResponse
13
+ from pydantic import BaseModel
14
+ from gradio_client import Client, handle_file
15
+
16
+ WANGP_URL = "https://00114dcf4c286c050a.gradio.live/"
17
+ OUTPUT_DIR = Path(__file__).resolve().parent.parent / "flux2_outputs"
18
+ OUTPUT_DIR.mkdir(exist_ok=True)
19
+
20
+ _client = None
21
+ _client_lock = threading.Lock()
22
+
23
+ def _get_client():
24
+ global _client
25
+ if _client is not None:
26
+ return _client
27
+ with _client_lock:
28
+ if _client is not None:
29
+ return _client
30
+ try:
31
+ _client = Client(WANGP_URL)
32
+ except Exception as e:
33
+ raise RuntimeError(f"Cannot connect to Wangp API at {WANGP_URL}: {e}")
34
+ return _client
35
+
36
+ DEFAULT_VIDEO = {
37
+ "video": handle_file(
38
+ "https://github.com/gradio-app/gradio/raw/main/demo/video_component/files/world.mp4"
39
+ )
40
+ }
41
+ DEFAULT_IMAGE = handle_file(
42
+ "https://raw.githubusercontent.com/gradio-app/gradio/main/test/test_files/bus.png"
43
+ )
44
+ DEFAULT_AUDIO = handle_file(
45
+ "https://github.com/gradio-app/gradio/raw/main/test/test_files/audio_sample.wav"
46
+ )
47
+ DEFAULT_FILE = handle_file(
48
+ "https://github.com/gradio-app/gradio/raw/main/test/test_files/sample_file.pdf"
49
+ )
50
+
51
+ _tasks: dict[str, dict[str, Any]] = {}
52
+ _tasks_lock = threading.Lock()
53
+
54
+ router = APIRouter(prefix="/api/flux2", tags=["flux2"])
55
+
56
+
57
+ class GenRequest(BaseModel):
58
+ prompt: str
59
+ negative_prompt: str = ""
60
+ resolution: str = "1024x1024"
61
+ num_inference_steps: int = 4
62
+ guidance_scale: float = 5.0
63
+ seed: int = -1
64
+ batch_size: int = 1
65
+
66
+
67
+ def _extract_images(result) -> list:
68
+ if not result:
69
+ return []
70
+ if isinstance(result, (list, tuple)):
71
+ for item in result:
72
+ if isinstance(item, list):
73
+ return item
74
+ if isinstance(item, dict) and ("image" in item or "video" in item):
75
+ return [item]
76
+ if all(isinstance(x, dict) for x in result if x):
77
+ return result
78
+ return []
79
+
80
+
81
+ def _download_image(img_info: Any, dest: Path):
82
+ if isinstance(img_info, dict):
83
+ img = img_info.get("image", img_info.get("video", {}))
84
+ if isinstance(img, dict):
85
+ path = img.get("path", img.get("url", ""))
86
+ else:
87
+ path = str(img) if img else ""
88
+ else:
89
+ path = str(img_info)
90
+
91
+ if not path:
92
+ raise ValueError("No image path found")
93
+
94
+ if path.startswith("/"):
95
+ url = f"{WANGP_URL.rstrip('/')}/file={path.lstrip('/')}"
96
+ elif path.startswith("http"):
97
+ url = path
98
+ else:
99
+ url = f"{WANGP_URL.rstrip('/')}/file={path}"
100
+
101
+ with httpx.Client(timeout=120.0, follow_redirects=True) as c:
102
+ resp = c.get(url)
103
+ resp.raise_for_status()
104
+ dest.write_bytes(resp.content)
105
+
106
+
107
+ def _generate(task_id: str, params: dict):
108
+ try:
109
+ with _tasks_lock:
110
+ _tasks[task_id] = {"status": "processing"}
111
+
112
+ result = _get_client().predict(
113
+ prompt=params["prompt"],
114
+ negative_prompt=params.get("negative_prompt", ""),
115
+ alt_prompt="",
116
+ resolution=params.get("resolution", "1024x1024"),
117
+ video_length=81,
118
+ duration_seconds=0,
119
+ pause_seconds=0,
120
+ batch_size=params.get("batch_size", 1),
121
+ seed=params.get("seed", -1),
122
+ num_inference_steps=params.get("num_inference_steps", 4),
123
+ guidance_scale=params.get("guidance_scale", 5),
124
+ guidance2_scale=5,
125
+ guidance3_scale=5,
126
+ guidance_phases="1",
127
+ model_switch_phase="1",
128
+ flow_shift=5,
129
+ sample_solver="",
130
+ temperature=0.8,
131
+ top_p=0.9,
132
+ top_k=50,
133
+ loras_choices=[],
134
+ loras_multipliers="",
135
+ image_refs=[],
136
+ image_refs_relative_size=50,
137
+ remove_background_images_ref="0",
138
+ frames_positions="",
139
+ image_prompt_type="",
140
+ image_start=[],
141
+ image_end=[],
142
+ image_guide=DEFAULT_IMAGE,
143
+ image_mask=DEFAULT_IMAGE,
144
+ control_net_weight=1,
145
+ control_net_weight2=1,
146
+ denoising_strength=1,
147
+ masking_strength=0.25,
148
+ mask_expand=0,
149
+ model_mode="0",
150
+ prompt_enhancer="",
151
+ film_grain_intensity=0,
152
+ film_grain_saturation=0.5,
153
+ temporal_upsampling="",
154
+ spatial_upsampling="",
155
+ video_guide_outpainting="#",
156
+ video_guide_outpainting_ratio="",
157
+ override_profile="-1",
158
+ override_attention="auto",
159
+ alt_guidance_scale=1,
160
+ alt_scale=0,
161
+ audio_guidance_scale=1,
162
+ audio_scale=1,
163
+ embedded_guidance_scale=1,
164
+ repeat_generation=1,
165
+ multi_prompts_gen_type="PG",
166
+ multi_images_gen_type="0",
167
+ skip_steps_cache_type="",
168
+ skip_steps_multiplier="1.75",
169
+ skip_steps_start_step_perc=0,
170
+ self_refiner_setting="0",
171
+ self_refiner_f_uncertainty=0,
172
+ self_refiner_certain_percentage=0.999,
173
+ output_filename="",
174
+ mode="",
175
+ video_source=DEFAULT_VIDEO,
176
+ video_guide=DEFAULT_VIDEO,
177
+ video_mask=DEFAULT_VIDEO,
178
+ keep_frames_video_guide="",
179
+ keep_frames_video_source="",
180
+ input_video_strength=1,
181
+ audio_guide=DEFAULT_AUDIO,
182
+ audio_guide2=DEFAULT_AUDIO,
183
+ audio_source=DEFAULT_AUDIO,
184
+ replace_voice_sample=DEFAULT_AUDIO,
185
+ replace_voice_sample2=DEFAULT_AUDIO,
186
+ custom_guide=DEFAULT_FILE,
187
+ replace_voice_method="",
188
+ postprocess_audio="",
189
+ postprocess_audio_prompt="",
190
+ postprocess_audio_neg_prompt="",
191
+ speakers_locations="0:45 55:100",
192
+ sliding_window_size=81,
193
+ sliding_window_overlap=5,
194
+ sub_parallel_window_size=0,
195
+ sub_parallel_window_overlap=17,
196
+ sliding_window_color_correction_strength=0,
197
+ sliding_window_overlap_noise=0,
198
+ sliding_window_discard_last_frames=0,
199
+ sliding_window_trim_first_frames=0,
200
+ NAG_scale=1,
201
+ NAG_tau=3.5,
202
+ NAG_alpha=0.5,
203
+ perturbation_switch="0",
204
+ perturbation_layers=[9],
205
+ perturbation_start_perc=10,
206
+ perturbation_end_perc=90,
207
+ apg_switch="0",
208
+ cfg_star_switch="0",
209
+ cfg_zero_step=-1,
210
+ min_frames_if_references="1",
211
+ RIFLEx_setting="0",
212
+ force_fps="",
213
+ api_name="/save_inputs_1",
214
+ )
215
+
216
+ images = _extract_images(result)
217
+ saved = []
218
+ for i, img_info in enumerate(images):
219
+ try:
220
+ path = OUTPUT_DIR / f"{task_id}_{i}.png"
221
+ _download_image(img_info, path)
222
+ saved.append(
223
+ {
224
+ "index": i,
225
+ "url": f"/api/flux2/file/{task_id}_{i}.png",
226
+ "width": img_info.get("image", {}).get("width"),
227
+ "height": img_info.get("image", {}).get("height"),
228
+ }
229
+ if isinstance(img_info, dict)
230
+ else {"index": i, "url": f"/api/flux2/file/{task_id}_{i}.png"}
231
+ )
232
+ except Exception as e:
233
+ saved.append({"index": i, "error": str(e)})
234
+
235
+ with _tasks_lock:
236
+ _tasks[task_id] = {"status": "done", "images": saved}
237
+
238
+ except Exception as e:
239
+ import traceback
240
+
241
+ with _tasks_lock:
242
+ _tasks[task_id] = {
243
+ "status": "error",
244
+ "error": str(e),
245
+ "traceback": traceback.format_exc(),
246
+ }
247
+
248
+
249
+ @router.get("/health")
250
+ async def api_health():
251
+ try:
252
+ c = _get_client()
253
+ endpoints = c.view_api(all_endpoints=True, return_format="dict") if hasattr(c, "view_api") else {}
254
+ return {"connected": True, "url": WANGP_URL}
255
+ except Exception as e:
256
+ return {"connected": False, "url": WANGP_URL, "error": str(e)}
257
+
258
+
259
+ @router.post("/generate")
260
+ async def api_generate(req: GenRequest):
261
+ task_id = str(uuid.uuid4())
262
+ with _tasks_lock:
263
+ _tasks[task_id] = {"status": "queued"}
264
+
265
+ thread = threading.Thread(
266
+ target=_generate, args=(task_id, req.model_dump()), daemon=True
267
+ )
268
+ thread.start()
269
+
270
+ return {"task_id": task_id, "status": "queued"}
271
+
272
+
273
+ @router.get("/status/{task_id}")
274
+ async def api_status(task_id: str):
275
+ with _tasks_lock:
276
+ task = _tasks.get(task_id)
277
+ if not task:
278
+ raise HTTPException(404, "Task not found")
279
+ return task
280
+
281
+
282
+ @router.get("/file/{filename:path}")
283
+ async def api_file(filename: str):
284
+ path = OUTPUT_DIR / filename
285
+ if not path.exists():
286
+ raise HTTPException(404, "File not found")
287
+ return FileResponse(str(path), media_type="image/png")
backend/app/api/video.py CHANGED
@@ -8,7 +8,7 @@ import time
8
  from urllib.parse import unquote, urlparse
9
 
10
  import httpx
11
- from fastapi import APIRouter, HTTPException, UploadFile, File, Form
12
  from fastapi.responses import FileResponse, Response, StreamingResponse
13
  from pydantic import BaseModel
14
 
 
8
  from urllib.parse import unquote, urlparse
9
 
10
  import httpx
11
+ from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
12
  from fastapi.responses import FileResponse, Response, StreamingResponse
13
  from pydantic import BaseModel
14
 
backend/app/main.py CHANGED
@@ -31,7 +31,7 @@ _here = os.path.dirname(os.path.abspath(__file__))
31
  _parent = os.path.dirname(_here)
32
  sys.path.insert(0, _parent)
33
 
34
- from .api import common, download, cut, shuffle, dub, listing, video, auth, drive, history_api
35
 
36
  app = FastAPI(title="TNT Group - AI Content Create", version="1.0.0")
37
 
@@ -53,6 +53,7 @@ app.include_router(video.router)
53
  app.include_router(auth.router)
54
  app.include_router(drive.router)
55
  app.include_router(history_api.router)
 
56
 
57
 
58
  @app.get("/api/health")
 
31
  _parent = os.path.dirname(_here)
32
  sys.path.insert(0, _parent)
33
 
34
+ from .api import common, download, cut, shuffle, dub, listing, video, auth, drive, history_api, flux2
35
 
36
  app = FastAPI(title="TNT Group - AI Content Create", version="1.0.0")
37
 
 
53
  app.include_router(auth.router)
54
  app.include_router(drive.router)
55
  app.include_router(history_api.router)
56
+ app.include_router(flux2.router)
57
 
58
 
59
  @app.get("/api/health")
frontend/src/App.jsx CHANGED
@@ -9,6 +9,7 @@ import Guide from "./pages/Guide";
9
  import Support from "./pages/Support";
10
  import Placeholder from "./pages/Placeholder";
11
  import ListingImage from "./pages/ListingImage";
 
12
  import CreateVideo from "./pages/CreateVideo";
13
  import AdminUsers from "./pages/AdminUsers";
14
  import Login from "./pages/Login";
@@ -28,6 +29,7 @@ const router = createBrowserRouter([
28
  { path: "/xao", element: <ShuffleVideo /> },
29
  { path: "/dub", element: <DubVideo /> },
30
  { path: "/listing-anh", element: <ListingImage /> },
 
31
  { path: "/tao-video", element: <CreateVideo /> },
32
  { path: "/huong-dan", element: <Guide /> },
33
  { path: "/ho-tro", element: <Support /> },
 
9
  import Support from "./pages/Support";
10
  import Placeholder from "./pages/Placeholder";
11
  import ListingImage from "./pages/ListingImage";
12
+ import Flux2Gen from "./pages/Flux2Gen";
13
  import CreateVideo from "./pages/CreateVideo";
14
  import AdminUsers from "./pages/AdminUsers";
15
  import Login from "./pages/Login";
 
29
  { path: "/xao", element: <ShuffleVideo /> },
30
  { path: "/dub", element: <DubVideo /> },
31
  { path: "/listing-anh", element: <ListingImage /> },
32
+ { path: "/flux2", element: <Flux2Gen /> },
33
  { path: "/tao-video", element: <CreateVideo /> },
34
  { path: "/huong-dan", element: <Guide /> },
35
  { path: "/ho-tro", element: <Support /> },
frontend/src/components/Layout.jsx CHANGED
@@ -73,7 +73,7 @@ export default function Layout() {
73
  }}>
74
  <Outlet />
75
  <iframe
76
- src={connected && url ? `/api/video/gradio-proxy/` : "about:blank"}
77
  style={{
78
  flex: 1,
79
  width: "100%",
@@ -81,6 +81,7 @@ export default function Layout() {
81
  display: connected ? "block" : "none",
82
  }}
83
  allow="clipboard-read; clipboard-write; fullscreen"
 
84
  title="Gradio Console"
85
  />
86
  </div>
 
73
  }}>
74
  <Outlet />
75
  <iframe
76
+ src={connected && url ? url : "about:blank"}
77
  style={{
78
  flex: 1,
79
  width: "100%",
 
81
  display: connected ? "block" : "none",
82
  }}
83
  allow="clipboard-read; clipboard-write; fullscreen"
84
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
85
  title="Gradio Console"
86
  />
87
  </div>
frontend/src/pages/CreateVideo.jsx CHANGED
@@ -94,16 +94,6 @@ export default function CreateVideo() {
94
  )}
95
  </div>
96
 
97
- {/* Iframe Gradio UI khi đã kết nối */}
98
- {connected && (
99
- <iframe
100
- src={url}
101
- style={{ flex: 1, width: "100%", border: "none" }}
102
- allow="clipboard-read; clipboard-write"
103
- sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
104
- />
105
- )}
106
-
107
  {/* Hướng dẫn khi chưa kết nối */}
108
  {!connected && (
109
  <div style={{
 
94
  )}
95
  </div>
96
 
 
 
 
 
 
 
 
 
 
 
97
  {/* Hướng dẫn khi chưa kết nối */}
98
  {!connected && (
99
  <div style={{
frontend/src/pages/Flux2Gen.jsx ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useRef } from "react";
2
+ import { IcBolt, IcDownload as IcDl, IcImage, IcSliders } from "../components/Icons";
3
+
4
+ const RESOLUTIONS = [
5
+ { label: "1024×1024 (Square)", value: "1024x1024" },
6
+ { label: "1280×720 (Landscape)", value: "1280x720" },
7
+ { label: "720×1280 (Portrait)", value: "720x1280" },
8
+ { label: "1600×400 (Panorama)", value: "1600x400" },
9
+ { label: "1280×544 (Cinema)", value: "1280x544" },
10
+ { label: "1104×832 (3:2)", value: "1104x832" },
11
+ { label: "960×960 (Sq. small)", value: "960x960" },
12
+ ];
13
+
14
+ const STYLES = {
15
+ container: {
16
+ maxWidth: 960,
17
+ margin: "0 auto",
18
+ },
19
+ header: {
20
+ marginBottom: 28,
21
+ },
22
+ headerTitle: {
23
+ fontSize: 22,
24
+ fontWeight: 800,
25
+ margin: "0 0 4px",
26
+ display: "flex",
27
+ alignItems: "center",
28
+ gap: 10,
29
+ },
30
+ headerBadge: {
31
+ fontSize: 11,
32
+ fontWeight: 700,
33
+ padding: "3px 9px",
34
+ borderRadius: 10,
35
+ background: "var(--orange-soft)",
36
+ color: "var(--orange-700)",
37
+ },
38
+ headerSub: {
39
+ color: "var(--ink-2)",
40
+ fontSize: 14,
41
+ margin: 0,
42
+ },
43
+ card: {
44
+ background: "var(--white)",
45
+ border: "1px solid var(--line)",
46
+ borderRadius: "var(--radius)",
47
+ padding: 24,
48
+ boxShadow: "var(--shadow)",
49
+ marginBottom: 20,
50
+ },
51
+ label: {
52
+ fontSize: 13,
53
+ fontWeight: 600,
54
+ color: "var(--ink-2)",
55
+ marginBottom: 8,
56
+ display: "block",
57
+ },
58
+ textarea: {
59
+ width: "100%",
60
+ fontFamily: "inherit",
61
+ fontSize: 14,
62
+ color: "var(--ink)",
63
+ border: "1px solid var(--line)",
64
+ borderRadius: "var(--radius-sm)",
65
+ padding: "12px 14px",
66
+ background: "var(--white)",
67
+ outline: "none",
68
+ resize: "vertical",
69
+ minHeight: 120,
70
+ lineHeight: 1.6,
71
+ },
72
+ paramGrid: {
73
+ display: "grid",
74
+ gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
75
+ gap: 16,
76
+ },
77
+ paramLabel: {
78
+ fontSize: 12,
79
+ fontWeight: 600,
80
+ color: "var(--muted)",
81
+ marginBottom: 4,
82
+ display: "block",
83
+ },
84
+ input: {
85
+ width: "100%",
86
+ fontFamily: "inherit",
87
+ fontSize: 14,
88
+ color: "var(--ink)",
89
+ border: "1px solid var(--line)",
90
+ borderRadius: "var(--radius-sm)",
91
+ padding: "9px 11px",
92
+ background: "var(--white)",
93
+ outline: "none",
94
+ },
95
+ select: {
96
+ width: "100%",
97
+ fontFamily: "inherit",
98
+ fontSize: 14,
99
+ color: "var(--ink)",
100
+ border: "1px solid var(--line)",
101
+ borderRadius: "var(--radius-sm)",
102
+ padding: "9px 11px",
103
+ background: "var(--white)",
104
+ outline: "none",
105
+ cursor: "pointer",
106
+ },
107
+ generateBtn: {
108
+ display: "inline-flex",
109
+ alignItems: "center",
110
+ justifyContent: "center",
111
+ gap: 8,
112
+ border: "none",
113
+ background: "var(--orange)",
114
+ color: "#fff",
115
+ padding: "12px 28px",
116
+ borderRadius: "var(--radius-sm)",
117
+ fontSize: 15,
118
+ fontWeight: 700,
119
+ cursor: "pointer",
120
+ transition: "all .15s ease",
121
+ width: "100%",
122
+ marginTop: 18,
123
+ },
124
+ gallery: {
125
+ display: "grid",
126
+ gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
127
+ gap: 16,
128
+ },
129
+ imgCard: {
130
+ border: "1px solid var(--line)",
131
+ borderRadius: "var(--radius)",
132
+ padding: 12,
133
+ background: "var(--white)",
134
+ },
135
+ img: {
136
+ width: "100%",
137
+ height: "auto",
138
+ borderRadius: "var(--radius-sm)",
139
+ display: "block",
140
+ background: "#f5f0ea",
141
+ },
142
+ downloadBtn: {
143
+ display: "inline-flex",
144
+ alignItems: "center",
145
+ gap: 6,
146
+ border: "1px solid var(--line)",
147
+ background: "var(--white)",
148
+ color: "var(--ink)",
149
+ padding: "7px 14px",
150
+ borderRadius: "var(--radius-sm)",
151
+ fontSize: 13,
152
+ fontWeight: 600,
153
+ cursor: "pointer",
154
+ marginTop: 8,
155
+ width: "100%",
156
+ justifyContent: "center",
157
+ },
158
+ statusBox: {
159
+ padding: "14px 18px",
160
+ borderRadius: "var(--radius-sm)",
161
+ fontSize: 14,
162
+ marginBottom: 18,
163
+ display: "flex",
164
+ alignItems: "center",
165
+ gap: 10,
166
+ },
167
+ spinner: {
168
+ width: 20,
169
+ height: 20,
170
+ border: "3px solid var(--line)",
171
+ borderTopColor: "var(--orange)",
172
+ borderRadius: "50%",
173
+ animation: "proc-spin .8s linear infinite",
174
+ flex: "0 0 20px",
175
+ },
176
+ detailBtn: {
177
+ border: "none",
178
+ background: "none",
179
+ color: "var(--orange)",
180
+ cursor: "pointer",
181
+ fontSize: 12,
182
+ fontWeight: 600,
183
+ padding: 0,
184
+ marginTop: 6,
185
+ textDecoration: "underline",
186
+ },
187
+ };
188
+
189
+ function formatTime(s) {
190
+ const m = Math.floor(s / 60);
191
+ const sec = s % 60;
192
+ return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
193
+ }
194
+
195
+ export default function Flux2Gen() {
196
+ const [prompt, setPrompt] = useState("a cozy reading nook with warm sunlight");
197
+ const [negativePrompt, setNegativePrompt] = useState("");
198
+ const [resolution, setResolution] = useState("1024x1024");
199
+ const [steps, setSteps] = useState(4);
200
+ const [guidance, setGuidance] = useState(5);
201
+ const [seed, setSeed] = useState(-1);
202
+ const [batch, setBatch] = useState(1);
203
+ const [showAdvanced, setShowAdvanced] = useState(false);
204
+
205
+ const [running, setRunning] = useState(false);
206
+ const [status, setStatus] = useState(null);
207
+ const [images, setImages] = useState([]);
208
+ const [error, setError] = useState(null);
209
+ const pollRef = useRef(null);
210
+ const startTimeRef = useRef(null);
211
+
212
+ const pollStatus = useCallback(async (taskId) => {
213
+ try {
214
+ const res = await fetch(`/api/flux2/status/${taskId}`);
215
+ const data = await res.json();
216
+
217
+ if (data.status === "done") {
218
+ setRunning(false);
219
+ setStatus("done");
220
+ setImages(data.images || []);
221
+ clearInterval(pollRef.current);
222
+ return;
223
+ }
224
+ if (data.status === "error") {
225
+ setRunning(false);
226
+ setStatus("error");
227
+ setError(data.error || "Generation failed");
228
+ clearInterval(pollRef.current);
229
+ return;
230
+ }
231
+ if (data.status === "processing") {
232
+ const elapsed = startTimeRef.current
233
+ ? Math.floor((Date.now() - startTimeRef.current) / 1000)
234
+ : 0;
235
+ setStatus(`Generating... (${formatTime(elapsed)})`);
236
+ }
237
+ } catch (e) {
238
+ setRunning(false);
239
+ setStatus("error");
240
+ setError(e.message);
241
+ clearInterval(pollRef.current);
242
+ }
243
+ }, []);
244
+
245
+ const handleGenerate = useCallback(async () => {
246
+ if (!prompt.trim()) return;
247
+
248
+ setRunning(true);
249
+ setStatus("Queued...");
250
+ setImages([]);
251
+ setError(null);
252
+ startTimeRef.current = Date.now();
253
+
254
+ try {
255
+ const res = await fetch("/api/flux2/generate", {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: JSON.stringify({
259
+ prompt: prompt.trim(),
260
+ negative_prompt: negativePrompt.trim(),
261
+ resolution,
262
+ num_inference_steps: steps,
263
+ guidance_scale: guidance,
264
+ seed,
265
+ batch_size: batch,
266
+ }),
267
+ });
268
+ const data = await res.json();
269
+
270
+ if (!data.task_id) throw new Error(data.error || "No task_id returned");
271
+
272
+ setStatus("Queued...");
273
+ pollRef.current = setInterval(() => pollStatus(data.task_id), 1500);
274
+ pollStatus(data.task_id);
275
+ } catch (e) {
276
+ setRunning(false);
277
+ setStatus("error");
278
+ setError(e.message);
279
+ }
280
+ }, [prompt, negativePrompt, resolution, steps, guidance, seed, batch, pollStatus]);
281
+
282
+ const downloadAll = useCallback(() => {
283
+ images.forEach((img, i) => {
284
+ if (img.url) {
285
+ const a = document.createElement("a");
286
+ a.href = img.url;
287
+ a.download = `flux2_${i + 1}.png`;
288
+ a.click();
289
+ }
290
+ });
291
+ }, [images]);
292
+
293
+ return (
294
+ <div style={styles.container}>
295
+ <div style={styles.header}>
296
+ <div style={styles.headerTitle}>
297
+ <IcBolt size={22} />
298
+ Flux2 Klein 9B
299
+ <span style={styles.headerBadge}>Text-to-Image</span>
300
+ </div>
301
+ <p style={styles.headerSub}>
302
+ Generate images using Flux2 Klein 9B via Wangp API
303
+ </p>
304
+ </div>
305
+
306
+ <div style={styles.card}>
307
+ <div style={{ marginBottom: 16 }}>
308
+ <label style={styles.label}>Prompt</label>
309
+ <textarea
310
+ style={styles.textarea}
311
+ value={prompt}
312
+ onChange={(e) => setPrompt(e.target.value)}
313
+ placeholder="Describe what you want to see..."
314
+ disabled={running}
315
+ />
316
+ </div>
317
+
318
+ <div>
319
+ <button
320
+ onClick={() => setShowAdvanced(!showAdvanced)}
321
+ style={{
322
+ ...styles.detailBtn,
323
+ display: "flex",
324
+ alignItems: "center",
325
+ gap: 6,
326
+ marginBottom: showAdvanced ? 16 : 0,
327
+ }}
328
+ >
329
+ <IcSliders size={14} />
330
+ {showAdvanced ? "Hide" : "Show"} advanced parameters
331
+ </button>
332
+ </div>
333
+
334
+ {showAdvanced && (
335
+ <>
336
+ <div style={{ marginBottom: 16 }}>
337
+ <label style={styles.label}>Negative Prompt</label>
338
+ <textarea
339
+ style={{ ...styles.textarea, minHeight: 60 }}
340
+ value={negativePrompt}
341
+ onChange={(e) => setNegativePrompt(e.target.value)}
342
+ placeholder="What to avoid in the image..."
343
+ disabled={running}
344
+ />
345
+ </div>
346
+
347
+ <div style={styles.paramGrid}>
348
+ <div>
349
+ <label style={styles.paramLabel}>Resolution</label>
350
+ <select
351
+ style={styles.select}
352
+ value={resolution}
353
+ onChange={(e) => setResolution(e.target.value)}
354
+ disabled={running}
355
+ >
356
+ {RESOLUTIONS.map((r) => (
357
+ <option key={r.value} value={r.value}>
358
+ {r.label}
359
+ </option>
360
+ ))}
361
+ </select>
362
+ </div>
363
+
364
+ <div>
365
+ <label style={styles.paramLabel}>Steps</label>
366
+ <input
367
+ style={styles.input}
368
+ type="number"
369
+ value={steps}
370
+ onChange={(e) => setSteps(Number(e.target.value))}
371
+ min={1}
372
+ max={50}
373
+ disabled={running}
374
+ />
375
+ </div>
376
+
377
+ <div>
378
+ <label style={styles.paramLabel}>Guidance</label>
379
+ <input
380
+ style={styles.input}
381
+ type="number"
382
+ value={guidance}
383
+ onChange={(e) => setGuidance(Number(e.target.value))}
384
+ min={1}
385
+ max={20}
386
+ step={0.5}
387
+ disabled={running}
388
+ />
389
+ </div>
390
+
391
+ <div>
392
+ <label style={styles.paramLabel}>Seed (-1 = random)</label>
393
+ <input
394
+ style={styles.input}
395
+ type="number"
396
+ value={seed}
397
+ onChange={(e) => setSeed(Number(e.target.value))}
398
+ min={-1}
399
+ disabled={running}
400
+ />
401
+ </div>
402
+
403
+ <div>
404
+ <label style={styles.paramLabel}>Batch Size</label>
405
+ <input
406
+ style={styles.input}
407
+ type="number"
408
+ value={batch}
409
+ onChange={(e) => setBatch(Number(e.target.value))}
410
+ min={1}
411
+ max={4}
412
+ disabled={running}
413
+ />
414
+ </div>
415
+ </div>
416
+ </>
417
+ )}
418
+
419
+ <button
420
+ style={{
421
+ ...styles.generateBtn,
422
+ opacity: running || !prompt.trim() ? 0.6 : 1,
423
+ cursor:
424
+ running || !prompt.trim() ? "not-allowed" : "pointer",
425
+ }}
426
+ onClick={handleGenerate}
427
+ disabled={running || !prompt.trim()}
428
+ >
429
+ {running ? (
430
+ <>
431
+ <div style={styles.spinner} />
432
+ Generating...
433
+ </>
434
+ ) : (
435
+ <>
436
+ <IcBolt size={18} />
437
+ Generate
438
+ </>
439
+ )}
440
+ </button>
441
+ </div>
442
+
443
+ {status && !error && !images.length && (
444
+ <div
445
+ style={{
446
+ ...styles.statusBox,
447
+ background: "var(--orange-tint)",
448
+ border: "1px solid #f6d9c2",
449
+ color: "var(--ink-2)",
450
+ }}
451
+ >
452
+ <div style={styles.spinner} />
453
+ {status}
454
+ </div>
455
+ )}
456
+
457
+ {error && (
458
+ <div
459
+ style={{
460
+ ...styles.statusBox,
461
+ background: "var(--red-soft)",
462
+ border: "1px solid #f5c6c6",
463
+ color: "var(--red)",
464
+ }}
465
+ >
466
+ <span>✕</span>
467
+ {error}
468
+ </div>
469
+ )}
470
+
471
+ {images.length > 0 && (
472
+ <div>
473
+ <div
474
+ style={{
475
+ display: "flex",
476
+ justifyContent: "space-between",
477
+ alignItems: "center",
478
+ marginBottom: 14,
479
+ }}
480
+ >
481
+ <div className="section-title">
482
+ Results ({images.length})
483
+ </div>
484
+ <button
485
+ style={styles.downloadBtn}
486
+ onClick={downloadAll}
487
+ >
488
+ <IcDl size={14} />
489
+ Download All
490
+ </button>
491
+ </div>
492
+
493
+ <div style={styles.gallery}>
494
+ {images.map((img, i) =>
495
+ img.error ? (
496
+ <div key={i} style={styles.imgCard}>
497
+ <div
498
+ style={{
499
+ padding: 40,
500
+ textAlign: "center",
501
+ color: "var(--red)",
502
+ fontSize: 13,
503
+ }}
504
+ >
505
+ Failed: {img.error}
506
+ </div>
507
+ </div>
508
+ ) : (
509
+ <div key={i} style={styles.imgCard}>
510
+ <img
511
+ src={img.url}
512
+ alt={`Generated ${i + 1}`}
513
+ style={styles.img}
514
+ loading="lazy"
515
+ />
516
+ <button
517
+ style={styles.downloadBtn}
518
+ onClick={() => {
519
+ const a = document.createElement("a");
520
+ a.href = img.url;
521
+ a.download = `flux2_${i + 1}.png`;
522
+ a.click();
523
+ }}
524
+ >
525
+ <IcDl size={14} />
526
+ Download
527
+ </button>
528
+ </div>
529
+ )
530
+ )}
531
+ </div>
532
+ </div>
533
+ )}
534
+ </div>
535
+ );
536
+ }