seawolf2357 commited on
Commit
3dfb56c
ยท
verified ยท
1 Parent(s): 778f5b9

Create app-backup.py

Browse files
Files changed (1) hide show
  1. app-backup.py +1136 -0
app-backup.py ADDED
@@ -0,0 +1,1136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import numpy as np
3
+ import random
4
+ import torch
5
+ import spaces
6
+
7
+ from PIL import Image
8
+ from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageEditPlusPipeline
9
+
10
+ MAX_SEED = np.iinfo(np.int32).max
11
+
12
+ # --- Model Loading ---
13
+ dtype = torch.bfloat16
14
+ device = "cuda" if torch.cuda.is_available() else "cpu"
15
+
16
+ pipe = QwenImageEditPlusPipeline.from_pretrained(
17
+ "Qwen/Qwen-Image-Edit-2511",
18
+ torch_dtype=dtype
19
+ ).to(device)
20
+
21
+ # Enable VAE tiling to reduce memory usage
22
+ pipe.vae.enable_tiling()
23
+
24
+ # Load the lightning LoRA for fast inference
25
+ pipe.load_lora_weights(
26
+ "lightx2v/Qwen-Image-Edit-2511-Lightning",
27
+ weight_name="Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
28
+ adapter_name="lightning"
29
+ )
30
+
31
+ # Load the multi-angles LoRA
32
+ pipe.load_lora_weights(
33
+ "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA",
34
+ weight_name="qwen-image-edit-2511-multiple-angles-lora.safetensors",
35
+ adapter_name="angles"
36
+ )
37
+
38
+ pipe.set_adapters(["lightning", "angles"], adapter_weights=[1.0, 1.0])
39
+
40
+ # --- Prompt Building ---
41
+
42
+ # Azimuth mappings (8 positions)
43
+ AZIMUTH_MAP = {
44
+ 0: "front view",
45
+ 45: "front-right quarter view",
46
+ 90: "right side view",
47
+ 135: "back-right quarter view",
48
+ 180: "back view",
49
+ 225: "back-left quarter view",
50
+ 270: "left side view",
51
+ 315: "front-left quarter view"
52
+ }
53
+
54
+ # Elevation mappings (4 positions)
55
+ ELEVATION_MAP = {
56
+ -30: "low-angle shot",
57
+ 0: "eye-level shot",
58
+ 30: "elevated shot",
59
+ 60: "high-angle shot"
60
+ }
61
+
62
+ # Distance mappings (3 positions)
63
+ DISTANCE_MAP = {
64
+ 0.6: "close-up",
65
+ 1.0: "medium shot",
66
+ 1.8: "wide shot"
67
+ }
68
+
69
+
70
+ def snap_to_nearest(value, options):
71
+ """Snap a value to the nearest option in a list."""
72
+ return min(options, key=lambda x: abs(x - value))
73
+
74
+
75
+ def build_camera_prompt(azimuth: float, elevation: float, distance: float) -> str:
76
+ """
77
+ Build a camera prompt from azimuth, elevation, and distance values.
78
+ """
79
+ azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
80
+ elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
81
+ distance_snapped = snap_to_nearest(distance, list(DISTANCE_MAP.keys()))
82
+
83
+ azimuth_name = AZIMUTH_MAP[azimuth_snapped]
84
+ elevation_name = ELEVATION_MAP[elevation_snapped]
85
+ distance_name = DISTANCE_MAP[distance_snapped]
86
+
87
+ return f"<sks> {azimuth_name} {elevation_name} {distance_name}"
88
+
89
+
90
+ @spaces.GPU
91
+ def infer_camera_edit(
92
+ image: Image.Image,
93
+ azimuth: float = 0.0,
94
+ elevation: float = 0.0,
95
+ distance: float = 1.0,
96
+ seed: int = 0,
97
+ randomize_seed: bool = True,
98
+ guidance_scale: float = 1.0,
99
+ num_inference_steps: int = 4,
100
+ height: int = 768,
101
+ width: int = 768,
102
+ ):
103
+ """
104
+ Edit the camera angle of an image using Qwen Image Edit 2511 with multi-angles LoRA.
105
+ """
106
+ progress = gr.Progress(track_tqdm=True)
107
+
108
+ prompt = build_camera_prompt(azimuth, elevation, distance)
109
+ print(f"Generated Prompt: {prompt}")
110
+
111
+ if randomize_seed:
112
+ seed = random.randint(0, MAX_SEED)
113
+ generator = torch.Generator(device=device).manual_seed(seed)
114
+
115
+ if image is None:
116
+ raise gr.Error("Please upload an image first.")
117
+
118
+ pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
119
+
120
+ # Ensure dimensions are multiples of 8
121
+ height = (height // 8) * 8
122
+ width = (width // 8) * 8
123
+
124
+ # Limit max resolution to prevent OOM
125
+ max_dim = 768
126
+ if height > max_dim or width > max_dim:
127
+ scale = max_dim / max(height, width)
128
+ height = int(height * scale)
129
+ width = int(width * scale)
130
+ height = (height // 8) * 8
131
+ width = (width // 8) * 8
132
+
133
+ result = pipe(
134
+ image=[pil_image],
135
+ prompt=prompt,
136
+ height=height,
137
+ width=width,
138
+ num_inference_steps=num_inference_steps,
139
+ generator=generator,
140
+ guidance_scale=guidance_scale,
141
+ num_images_per_prompt=1,
142
+ ).images[0]
143
+
144
+ return result, seed, prompt
145
+
146
+
147
+ def update_dimensions_on_upload(image):
148
+ """Compute recommended dimensions preserving aspect ratio."""
149
+ if image is None:
150
+ return 768, 768
151
+
152
+ original_width, original_height = image.size
153
+ max_dim = 768 # Reduced to prevent OOM
154
+
155
+ if original_width > original_height:
156
+ new_width = max_dim
157
+ aspect_ratio = original_height / original_width
158
+ new_height = int(new_width * aspect_ratio)
159
+ else:
160
+ new_height = max_dim
161
+ aspect_ratio = original_width / original_height
162
+ new_width = int(new_height * aspect_ratio)
163
+
164
+ new_width = (new_width // 8) * 8
165
+ new_height = (new_height // 8) * 8
166
+
167
+ return new_width, new_height
168
+
169
+
170
+ # --- Comic Style CSS ---
171
+ COMIC_CSS = """
172
+ @import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
173
+
174
+ .gradio-container {
175
+ background-color: #FEF9C3 !important;
176
+ background-image: radial-gradient(#1F2937 1px, transparent 1px) !important;
177
+ background-size: 20px 20px !important;
178
+ min-height: 100vh !important;
179
+ font-family: 'Comic Neue', cursive, sans-serif !important;
180
+ }
181
+
182
+ footer, .footer, .gradio-container footer, .built-with, [class*="footer"], .gradio-footer, a[href*="gradio.app"] {
183
+ display: none !important;
184
+ visibility: hidden !important;
185
+ height: 0 !important;
186
+ }
187
+
188
+ .header-container {
189
+ text-align: center;
190
+ padding: 25px 20px;
191
+ background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
192
+ border: 4px solid #1F2937;
193
+ border-radius: 12px;
194
+ margin-bottom: 20px;
195
+ box-shadow: 8px 8px 0 #1F2937;
196
+ position: relative;
197
+ }
198
+
199
+ .header-title {
200
+ font-family: 'Bangers', cursive !important;
201
+ color: #FFF !important;
202
+ font-size: 2.8rem !important;
203
+ text-shadow: 3px 3px 0 #1F2937 !important;
204
+ letter-spacing: 3px !important;
205
+ margin: 0 !important;
206
+ }
207
+
208
+ .header-subtitle {
209
+ font-family: 'Comic Neue', cursive !important;
210
+ font-size: 1.1rem !important;
211
+ color: #FEF9C3 !important;
212
+ margin-top: 8px !important;
213
+ font-weight: 700 !important;
214
+ }
215
+
216
+ .stats-badge {
217
+ display: inline-block;
218
+ background: #FACC15;
219
+ color: #1F2937;
220
+ padding: 6px 14px;
221
+ border-radius: 20px;
222
+ font-size: 0.9rem;
223
+ margin: 3px;
224
+ font-weight: 700;
225
+ border: 2px solid #1F2937;
226
+ box-shadow: 2px 2px 0 #1F2937;
227
+ }
228
+
229
+ .gr-panel, .gr-box, .gr-form, .block, .gr-group {
230
+ background: #FFF !important;
231
+ border: 3px solid #1F2937 !important;
232
+ border-radius: 8px !important;
233
+ box-shadow: 5px 5px 0 #1F2937 !important;
234
+ }
235
+
236
+ .gr-button-primary, button.primary, .gr-button.primary {
237
+ background: linear-gradient(135deg, #EF4444 0%, #F97316 100%) !important;
238
+ border: 3px solid #1F2937 !important;
239
+ border-radius: 8px !important;
240
+ color: #FFF !important;
241
+ font-family: 'Bangers', cursive !important;
242
+ font-size: 1.3rem !important;
243
+ letter-spacing: 2px !important;
244
+ padding: 12px 24px !important;
245
+ box-shadow: 4px 4px 0 #1F2937 !important;
246
+ text-shadow: 1px 1px 0 #1F2937 !important;
247
+ transition: all 0.2s ease !important;
248
+ }
249
+
250
+ .gr-button-primary:hover, button.primary:hover {
251
+ background: linear-gradient(135deg, #DC2626 0%, #EA580C 100%) !important;
252
+ transform: translate(-2px, -2px) !important;
253
+ box-shadow: 6px 6px 0 #1F2937 !important;
254
+ }
255
+
256
+ .gr-button-primary:active, button.primary:active {
257
+ transform: translate(2px, 2px) !important;
258
+ box-shadow: 2px 2px 0 #1F2937 !important;
259
+ }
260
+
261
+ textarea, input[type="text"], input[type="number"] {
262
+ background: #FFF !important;
263
+ border: 3px solid #1F2937 !important;
264
+ border-radius: 8px !important;
265
+ color: #1F2937 !important;
266
+ font-family: 'Comic Neue', cursive !important;
267
+ font-weight: 700 !important;
268
+ }
269
+
270
+ textarea:focus, input[type="text"]:focus {
271
+ border-color: #3B82F6 !important;
272
+ box-shadow: 3px 3px 0 #3B82F6 !important;
273
+ }
274
+
275
+ .info-box {
276
+ background: linear-gradient(135deg, #FACC15 0%, #FDE047 100%) !important;
277
+ border: 3px solid #1F2937 !important;
278
+ border-radius: 8px !important;
279
+ padding: 12px 15px !important;
280
+ margin: 10px 0 !important;
281
+ box-shadow: 4px 4px 0 #1F2937 !important;
282
+ font-family: 'Comic Neue', cursive !important;
283
+ font-weight: 700 !important;
284
+ color: #1F2937 !important;
285
+ }
286
+
287
+ .result-box textarea {
288
+ background: #1F2937 !important;
289
+ color: #10B981 !important;
290
+ font-family: 'Courier New', monospace !important;
291
+ border: 3px solid #10B981 !important;
292
+ border-radius: 8px !important;
293
+ box-shadow: 4px 4px 0 #10B981 !important;
294
+ }
295
+
296
+ label, .gr-input-label, .gr-block-label {
297
+ color: #1F2937 !important;
298
+ font-family: 'Comic Neue', cursive !important;
299
+ font-weight: 700 !important;
300
+ }
301
+
302
+ .gr-accordion {
303
+ background: #E0F2FE !important;
304
+ border: 3px solid #1F2937 !important;
305
+ border-radius: 8px !important;
306
+ box-shadow: 4px 4px 0 #1F2937 !important;
307
+ }
308
+
309
+ .tab-nav button {
310
+ font-family: 'Comic Neue', cursive !important;
311
+ font-weight: 700 !important;
312
+ border: 2px solid #1F2937 !important;
313
+ margin: 2px !important;
314
+ }
315
+
316
+ .tab-nav button.selected {
317
+ background: #3B82F6 !important;
318
+ color: #FFF !important;
319
+ box-shadow: 3px 3px 0 #1F2937 !important;
320
+ }
321
+
322
+ .footer-comic {
323
+ text-align: center;
324
+ padding: 20px;
325
+ background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
326
+ border: 4px solid #1F2937;
327
+ border-radius: 12px;
328
+ margin-top: 20px;
329
+ box-shadow: 6px 6px 0 #1F2937;
330
+ }
331
+
332
+ .footer-comic p {
333
+ font-family: 'Comic Neue', cursive !important;
334
+ color: #FFF !important;
335
+ margin: 5px 0 !important;
336
+ font-weight: 700 !important;
337
+ }
338
+
339
+ ::-webkit-scrollbar {
340
+ width: 12px;
341
+ height: 12px;
342
+ }
343
+
344
+ ::-webkit-scrollbar-track {
345
+ background: #FEF9C3;
346
+ border: 2px solid #1F2937;
347
+ }
348
+
349
+ ::-webkit-scrollbar-thumb {
350
+ background: #3B82F6;
351
+ border: 2px solid #1F2937;
352
+ border-radius: 6px;
353
+ }
354
+
355
+ ::-webkit-scrollbar-thumb:hover {
356
+ background: #EF4444;
357
+ }
358
+
359
+ ::selection {
360
+ background: #FACC15;
361
+ color: #1F2937;
362
+ }
363
+
364
+ /* 3D Camera Control Styling */
365
+ #camera-3d-control {
366
+ min-height: 450px;
367
+ border: 3px solid #1F2937 !important;
368
+ border-radius: 12px !important;
369
+ box-shadow: 5px 5px 0 #1F2937 !important;
370
+ overflow: hidden;
371
+ }
372
+
373
+ /* Slider Styling */
374
+ input[type="range"] {
375
+ accent-color: #3B82F6;
376
+ }
377
+
378
+ .gr-slider input[type="range"]::-webkit-slider-thumb {
379
+ background: #EF4444 !important;
380
+ border: 2px solid #1F2937 !important;
381
+ }
382
+
383
+ /* Image Container */
384
+ .gr-image {
385
+ border: 3px solid #1F2937 !important;
386
+ border-radius: 8px !important;
387
+ box-shadow: 4px 4px 0 #1F2937 !important;
388
+ }
389
+
390
+ /* Control Section */
391
+ .control-section {
392
+ background: linear-gradient(135deg, #E0F2FE 0%, #DBEAFE 100%) !important;
393
+ border: 3px solid #1F2937 !important;
394
+ border-radius: 12px !important;
395
+ padding: 15px !important;
396
+ margin: 10px 0 !important;
397
+ box-shadow: 4px 4px 0 #1F2937 !important;
398
+ }
399
+
400
+ /* Hide Hugging Face elements */
401
+ .huggingface-space-link,
402
+ a[href*="huggingface.co/spaces"],
403
+ button[class*="share"],
404
+ .share-button,
405
+ [class*="hf-logo"],
406
+ .gr-share-btn,
407
+ #hf-logo,
408
+ .hf-icon,
409
+ svg[class*="hf"],
410
+ div[class*="huggingface"],
411
+ a[class*="huggingface"],
412
+ .svelte-1rjryqp,
413
+ header a[href*="huggingface"],
414
+ .space-header,
415
+ div.absolute.right-0 a[href*="huggingface"],
416
+ .gr-group > a[href*="huggingface"],
417
+ a[target="_blank"][href*="huggingface.co"] {
418
+ display: none !important;
419
+ visibility: hidden !important;
420
+ opacity: 0 !important;
421
+ pointer-events: none !important;
422
+ width: 0 !important;
423
+ height: 0 !important;
424
+ overflow: hidden !important;
425
+ }
426
+
427
+ /* Quality Badge */
428
+ .quality-badge {
429
+ display: inline-block;
430
+ background: linear-gradient(135deg, #10B981 0%, #059669 100%);
431
+ color: white;
432
+ padding: 4px 12px;
433
+ border-radius: 15px;
434
+ font-size: 0.8rem;
435
+ font-weight: bold;
436
+ border: 2px solid #1F2937;
437
+ margin-left: 8px;
438
+ }
439
+
440
+ #col-container {
441
+ max-width: 1200px;
442
+ margin: 0 auto;
443
+ }
444
+
445
+ .dark .progress-text {
446
+ color: white !important;
447
+ }
448
+ """
449
+
450
+ # --- 3D Camera Control Component ---
451
+ class CameraControl3D(gr.HTML):
452
+ """
453
+ A 3D camera control component using Three.js.
454
+ """
455
+ def __init__(self, value=None, imageUrl=None, **kwargs):
456
+ if value is None:
457
+ value = {"azimuth": 0, "elevation": 0, "distance": 1.0}
458
+
459
+ html_template = """
460
+ <div id="camera-control-wrapper" style="width: 100%; height: 450px; position: relative; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 12px; overflow: hidden;">
461
+ <div id="prompt-overlay" style="position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); padding: 10px 20px; border-radius: 10px; font-family: 'Bangers', monospace; font-size: 14px; color: #00ff88; white-space: nowrap; z-index: 10; border: 2px solid #00ff88; box-shadow: 0 0 15px rgba(0,255,136,0.3);"></div>
462
+ <div id="control-hints" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.75); padding: 8px 12px; border-radius: 8px; font-family: 'Comic Neue', sans-serif; font-size: 11px; color: #fff; z-index: 10; border: 2px solid #3B82F6;">
463
+ ๐ŸŸข Azimuth &nbsp; ๐Ÿฉท Elevation &nbsp; ๐ŸŸ  Distance
464
+ </div>
465
+ </div>
466
+ """
467
+
468
+ js_on_load = """
469
+ (() => {
470
+ const wrapper = element.querySelector('#camera-control-wrapper');
471
+ const promptOverlay = element.querySelector('#prompt-overlay');
472
+
473
+ const initScene = () => {
474
+ if (typeof THREE === 'undefined') {
475
+ setTimeout(initScene, 100);
476
+ return;
477
+ }
478
+
479
+ const scene = new THREE.Scene();
480
+ scene.background = new THREE.Color(0x1a1a2e);
481
+
482
+ const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
483
+ camera.position.set(4.5, 3, 4.5);
484
+ camera.lookAt(0, 0.75, 0);
485
+
486
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
487
+ renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
488
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
489
+ wrapper.insertBefore(renderer.domElement, promptOverlay);
490
+
491
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
492
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
493
+ dirLight.position.set(5, 10, 5);
494
+ scene.add(dirLight);
495
+
496
+ const gridHelper = new THREE.GridHelper(8, 16, 0x3B82F6, 0x1e3a5f);
497
+ scene.add(gridHelper);
498
+
499
+ const CENTER = new THREE.Vector3(0, 0.75, 0);
500
+ const BASE_DISTANCE = 1.6;
501
+ const AZIMUTH_RADIUS = 2.4;
502
+ const ELEVATION_RADIUS = 1.8;
503
+
504
+ let azimuthAngle = props.value?.azimuth || 0;
505
+ let elevationAngle = props.value?.elevation || 0;
506
+ let distanceFactor = props.value?.distance || 1.0;
507
+
508
+ const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
509
+ const elevationSteps = [-30, 0, 30, 60];
510
+ const distanceSteps = [0.6, 1.0, 1.4];
511
+
512
+ const azimuthNames = {
513
+ 0: 'front view', 45: 'front-right quarter view', 90: 'right side view',
514
+ 135: 'back-right quarter view', 180: 'back view', 225: 'back-left quarter view',
515
+ 270: 'left side view', 315: 'front-left quarter view'
516
+ };
517
+ const elevationNames = { '-30': 'low-angle shot', '0': 'eye-level shot', '30': 'elevated shot', '60': 'high-angle shot' };
518
+ const distanceNames = { '0.6': 'close-up', '1': 'medium shot', '1.4': 'wide shot' };
519
+
520
+ function snapToNearest(value, steps) {
521
+ return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
522
+ }
523
+
524
+ function createPlaceholderTexture() {
525
+ const canvas = document.createElement('canvas');
526
+ canvas.width = 256;
527
+ canvas.height = 256;
528
+ const ctx = canvas.getContext('2d');
529
+
530
+ const gradient = ctx.createLinearGradient(0, 0, 256, 256);
531
+ gradient.addColorStop(0, '#3B82F6');
532
+ gradient.addColorStop(1, '#8B5CF6');
533
+ ctx.fillStyle = gradient;
534
+ ctx.fillRect(0, 0, 256, 256);
535
+
536
+ ctx.fillStyle = '#FEF9C3';
537
+ ctx.beginPath();
538
+ ctx.arc(128, 100, 50, 0, Math.PI * 2);
539
+ ctx.fill();
540
+
541
+ ctx.fillStyle = '#1F2937';
542
+ ctx.beginPath();
543
+ ctx.arc(110, 90, 8, 0, Math.PI * 2);
544
+ ctx.arc(146, 90, 8, 0, Math.PI * 2);
545
+ ctx.fill();
546
+
547
+ ctx.strokeStyle = '#1F2937';
548
+ ctx.lineWidth = 4;
549
+ ctx.beginPath();
550
+ ctx.arc(128, 105, 25, 0.2, Math.PI - 0.2);
551
+ ctx.stroke();
552
+
553
+ ctx.fillStyle = '#FFF';
554
+ ctx.font = 'bold 24px Comic Neue, sans-serif';
555
+ ctx.textAlign = 'center';
556
+ ctx.fillText('๐Ÿ“ท Upload Image', 128, 200);
557
+
558
+ return new THREE.CanvasTexture(canvas);
559
+ }
560
+
561
+ let currentTexture = createPlaceholderTexture();
562
+ const planeMaterial = new THREE.MeshBasicMaterial({ map: currentTexture, side: THREE.DoubleSide });
563
+ let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
564
+ targetPlane.position.copy(CENTER);
565
+ scene.add(targetPlane);
566
+
567
+ function updateTextureFromUrl(url) {
568
+ if (!url) {
569
+ planeMaterial.map = createPlaceholderTexture();
570
+ planeMaterial.needsUpdate = true;
571
+ scene.remove(targetPlane);
572
+ targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
573
+ targetPlane.position.copy(CENTER);
574
+ scene.add(targetPlane);
575
+ return;
576
+ }
577
+
578
+ const loader = new THREE.TextureLoader();
579
+ loader.crossOrigin = 'anonymous';
580
+ loader.load(url, (texture) => {
581
+ texture.minFilter = THREE.LinearFilter;
582
+ texture.magFilter = THREE.LinearFilter;
583
+ planeMaterial.map = texture;
584
+ planeMaterial.needsUpdate = true;
585
+
586
+ const img = texture.image;
587
+ if (img && img.width && img.height) {
588
+ const aspect = img.width / img.height;
589
+ const maxSize = 1.5;
590
+ let planeWidth, planeHeight;
591
+ if (aspect > 1) {
592
+ planeWidth = maxSize;
593
+ planeHeight = maxSize / aspect;
594
+ } else {
595
+ planeHeight = maxSize;
596
+ planeWidth = maxSize * aspect;
597
+ }
598
+ scene.remove(targetPlane);
599
+ targetPlane = new THREE.Mesh(
600
+ new THREE.PlaneGeometry(planeWidth, planeHeight),
601
+ planeMaterial
602
+ );
603
+ targetPlane.position.copy(CENTER);
604
+ scene.add(targetPlane);
605
+ }
606
+ });
607
+ }
608
+
609
+ if (props.imageUrl) {
610
+ updateTextureFromUrl(props.imageUrl);
611
+ }
612
+
613
+ const cameraGroup = new THREE.Group();
614
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0x6699cc, metalness: 0.5, roughness: 0.3 });
615
+ const body = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.22, 0.38), bodyMat);
616
+ cameraGroup.add(body);
617
+ const lens = new THREE.Mesh(
618
+ new THREE.CylinderGeometry(0.09, 0.11, 0.18, 16),
619
+ new THREE.MeshStandardMaterial({ color: 0x6699cc, metalness: 0.5, roughness: 0.3 })
620
+ );
621
+ lens.rotation.x = Math.PI / 2;
622
+ lens.position.z = 0.26;
623
+ cameraGroup.add(lens);
624
+ scene.add(cameraGroup);
625
+
626
+ const azimuthRing = new THREE.Mesh(
627
+ new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64),
628
+ new THREE.MeshStandardMaterial({ color: 0x00ff88, emissive: 0x00ff88, emissiveIntensity: 0.3 })
629
+ );
630
+ azimuthRing.rotation.x = Math.PI / 2;
631
+ azimuthRing.position.y = 0.05;
632
+ scene.add(azimuthRing);
633
+
634
+ const azimuthHandle = new THREE.Mesh(
635
+ new THREE.SphereGeometry(0.18, 16, 16),
636
+ new THREE.MeshStandardMaterial({ color: 0x00ff88, emissive: 0x00ff88, emissiveIntensity: 0.5 })
637
+ );
638
+ azimuthHandle.userData.type = 'azimuth';
639
+ scene.add(azimuthHandle);
640
+
641
+ const arcPoints = [];
642
+ for (let i = 0; i <= 32; i++) {
643
+ const angle = THREE.MathUtils.degToRad(-30 + (90 * i / 32));
644
+ arcPoints.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle)));
645
+ }
646
+ const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
647
+ const elevationArc = new THREE.Mesh(
648
+ new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false),
649
+ new THREE.MeshStandardMaterial({ color: 0xff69b4, emissive: 0xff69b4, emissiveIntensity: 0.3 })
650
+ );
651
+ scene.add(elevationArc);
652
+
653
+ const elevationHandle = new THREE.Mesh(
654
+ new THREE.SphereGeometry(0.18, 16, 16),
655
+ new THREE.MeshStandardMaterial({ color: 0xff69b4, emissive: 0xff69b4, emissiveIntensity: 0.5 })
656
+ );
657
+ elevationHandle.userData.type = 'elevation';
658
+ scene.add(elevationHandle);
659
+
660
+ const distanceLineGeo = new THREE.BufferGeometry();
661
+ const distanceLine = new THREE.Line(distanceLineGeo, new THREE.LineBasicMaterial({ color: 0xffa500 }));
662
+ scene.add(distanceLine);
663
+
664
+ const distanceHandle = new THREE.Mesh(
665
+ new THREE.SphereGeometry(0.18, 16, 16),
666
+ new THREE.MeshStandardMaterial({ color: 0xffa500, emissive: 0xffa500, emissiveIntensity: 0.5 })
667
+ );
668
+ distanceHandle.userData.type = 'distance';
669
+ scene.add(distanceHandle);
670
+
671
+ function updatePositions() {
672
+ const distance = BASE_DISTANCE * distanceFactor;
673
+ const azRad = THREE.MathUtils.degToRad(azimuthAngle);
674
+ const elRad = THREE.MathUtils.degToRad(elevationAngle);
675
+
676
+ const camX = distance * Math.sin(azRad) * Math.cos(elRad);
677
+ const camY = distance * Math.sin(elRad) + CENTER.y;
678
+ const camZ = distance * Math.cos(azRad) * Math.cos(elRad);
679
+
680
+ cameraGroup.position.set(camX, camY, camZ);
681
+ cameraGroup.lookAt(CENTER);
682
+
683
+ azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad));
684
+ elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad));
685
+
686
+ const orangeDist = distance - 0.5;
687
+ distanceHandle.position.set(
688
+ orangeDist * Math.sin(azRad) * Math.cos(elRad),
689
+ orangeDist * Math.sin(elRad) + CENTER.y,
690
+ orangeDist * Math.cos(azRad) * Math.cos(elRad)
691
+ );
692
+ distanceLineGeo.setFromPoints([cameraGroup.position.clone(), CENTER.clone()]);
693
+
694
+ const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
695
+ const elSnap = snapToNearest(elevationAngle, elevationSteps);
696
+ const distSnap = snapToNearest(distanceFactor, distanceSteps);
697
+ const distKey = distSnap === 1 ? '1' : distSnap.toFixed(1);
698
+ const prompt = '<sks> ' + azimuthNames[azSnap] + ' ' + elevationNames[String(elSnap)] + ' ' + distanceNames[distKey];
699
+ promptOverlay.textContent = prompt;
700
+ }
701
+
702
+ function updatePropsAndTrigger() {
703
+ const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
704
+ const elSnap = snapToNearest(elevationAngle, elevationSteps);
705
+ const distSnap = snapToNearest(distanceFactor, distanceSteps);
706
+
707
+ props.value = { azimuth: azSnap, elevation: elSnap, distance: distSnap };
708
+ trigger('change', props.value);
709
+ }
710
+
711
+ const raycaster = new THREE.Raycaster();
712
+ const mouse = new THREE.Vector2();
713
+ let isDragging = false;
714
+ let dragTarget = null;
715
+ let dragStartMouse = new THREE.Vector2();
716
+ let dragStartDistance = 1.0;
717
+ const intersection = new THREE.Vector3();
718
+
719
+ const canvas = renderer.domElement;
720
+
721
+ canvas.addEventListener('mousedown', (e) => {
722
+ const rect = canvas.getBoundingClientRect();
723
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
724
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
725
+
726
+ raycaster.setFromCamera(mouse, camera);
727
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle, distanceHandle]);
728
+
729
+ if (intersects.length > 0) {
730
+ isDragging = true;
731
+ dragTarget = intersects[0].object;
732
+ dragTarget.material.emissiveIntensity = 1.0;
733
+ dragTarget.scale.setScalar(1.3);
734
+ dragStartMouse.copy(mouse);
735
+ dragStartDistance = distanceFactor;
736
+ canvas.style.cursor = 'grabbing';
737
+ }
738
+ });
739
+
740
+ canvas.addEventListener('mousemove', (e) => {
741
+ const rect = canvas.getBoundingClientRect();
742
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
743
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
744
+
745
+ if (isDragging && dragTarget) {
746
+ raycaster.setFromCamera(mouse, camera);
747
+
748
+ if (dragTarget.userData.type === 'azimuth') {
749
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
750
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
751
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
752
+ if (azimuthAngle < 0) azimuthAngle += 360;
753
+ }
754
+ } else if (dragTarget.userData.type === 'elevation') {
755
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
756
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
757
+ const relY = intersection.y - CENTER.y;
758
+ const relZ = intersection.z;
759
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -30, 60);
760
+ }
761
+ } else if (dragTarget.userData.type === 'distance') {
762
+ const deltaY = mouse.y - dragStartMouse.y;
763
+ distanceFactor = THREE.MathUtils.clamp(dragStartDistance - deltaY * 1.5, 0.6, 1.4);
764
+ }
765
+ updatePositions();
766
+ } else {
767
+ raycaster.setFromCamera(mouse, camera);
768
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle, distanceHandle]);
769
+ [azimuthHandle, elevationHandle, distanceHandle].forEach(h => {
770
+ h.material.emissiveIntensity = 0.5;
771
+ h.scale.setScalar(1);
772
+ });
773
+ if (intersects.length > 0) {
774
+ intersects[0].object.material.emissiveIntensity = 0.8;
775
+ intersects[0].object.scale.setScalar(1.1);
776
+ canvas.style.cursor = 'grab';
777
+ } else {
778
+ canvas.style.cursor = 'default';
779
+ }
780
+ }
781
+ });
782
+
783
+ const onMouseUp = () => {
784
+ if (dragTarget) {
785
+ dragTarget.material.emissiveIntensity = 0.5;
786
+ dragTarget.scale.setScalar(1);
787
+
788
+ const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
789
+ const targetEl = snapToNearest(elevationAngle, elevationSteps);
790
+ const targetDist = snapToNearest(distanceFactor, distanceSteps);
791
+
792
+ const startAz = azimuthAngle, startEl = elevationAngle, startDist = distanceFactor;
793
+ const startTime = Date.now();
794
+
795
+ function animateSnap() {
796
+ const t = Math.min((Date.now() - startTime) / 200, 1);
797
+ const ease = 1 - Math.pow(1 - t, 3);
798
+
799
+ let azDiff = targetAz - startAz;
800
+ if (azDiff > 180) azDiff -= 360;
801
+ if (azDiff < -180) azDiff += 360;
802
+ azimuthAngle = startAz + azDiff * ease;
803
+ if (azimuthAngle < 0) azimuthAngle += 360;
804
+ if (azimuthAngle >= 360) azimuthAngle -= 360;
805
+
806
+ elevationAngle = startEl + (targetEl - startEl) * ease;
807
+ distanceFactor = startDist + (targetDist - startDist) * ease;
808
+
809
+ updatePositions();
810
+ if (t < 1) requestAnimationFrame(animateSnap);
811
+ else updatePropsAndTrigger();
812
+ }
813
+ animateSnap();
814
+ }
815
+ isDragging = false;
816
+ dragTarget = null;
817
+ canvas.style.cursor = 'default';
818
+ };
819
+
820
+ canvas.addEventListener('mouseup', onMouseUp);
821
+ canvas.addEventListener('mouseleave', onMouseUp);
822
+
823
+ canvas.addEventListener('touchstart', (e) => {
824
+ e.preventDefault();
825
+ const touch = e.touches[0];
826
+ const rect = canvas.getBoundingClientRect();
827
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
828
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
829
+
830
+ raycaster.setFromCamera(mouse, camera);
831
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle, distanceHandle]);
832
+
833
+ if (intersects.length > 0) {
834
+ isDragging = true;
835
+ dragTarget = intersects[0].object;
836
+ dragTarget.material.emissiveIntensity = 1.0;
837
+ dragTarget.scale.setScalar(1.3);
838
+ dragStartMouse.copy(mouse);
839
+ dragStartDistance = distanceFactor;
840
+ }
841
+ }, { passive: false });
842
+
843
+ canvas.addEventListener('touchmove', (e) => {
844
+ e.preventDefault();
845
+ const touch = e.touches[0];
846
+ const rect = canvas.getBoundingClientRect();
847
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
848
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
849
+
850
+ if (isDragging && dragTarget) {
851
+ raycaster.setFromCamera(mouse, camera);
852
+
853
+ if (dragTarget.userData.type === 'azimuth') {
854
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
855
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
856
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
857
+ if (azimuthAngle < 0) azimuthAngle += 360;
858
+ }
859
+ } else if (dragTarget.userData.type === 'elevation') {
860
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
861
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
862
+ const relY = intersection.y - CENTER.y;
863
+ const relZ = intersection.z;
864
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -30, 60);
865
+ }
866
+ } else if (dragTarget.userData.type === 'distance') {
867
+ const deltaY = mouse.y - dragStartMouse.y;
868
+ distanceFactor = THREE.MathUtils.clamp(dragStartDistance - deltaY * 1.5, 0.6, 1.4);
869
+ }
870
+ updatePositions();
871
+ }
872
+ }, { passive: false });
873
+
874
+ canvas.addEventListener('touchend', (e) => {
875
+ e.preventDefault();
876
+ onMouseUp();
877
+ }, { passive: false });
878
+
879
+ canvas.addEventListener('touchcancel', (e) => {
880
+ e.preventDefault();
881
+ onMouseUp();
882
+ }, { passive: false });
883
+
884
+ updatePositions();
885
+
886
+ function render() {
887
+ requestAnimationFrame(render);
888
+ renderer.render(scene, camera);
889
+ }
890
+ render();
891
+
892
+ new ResizeObserver(() => {
893
+ camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
894
+ camera.updateProjectionMatrix();
895
+ renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
896
+ }).observe(wrapper);
897
+
898
+ wrapper._updateFromProps = (newVal) => {
899
+ if (newVal && typeof newVal === 'object') {
900
+ azimuthAngle = newVal.azimuth ?? azimuthAngle;
901
+ elevationAngle = newVal.elevation ?? elevationAngle;
902
+ distanceFactor = newVal.distance ?? distanceFactor;
903
+ updatePositions();
904
+ }
905
+ };
906
+
907
+ wrapper._updateTexture = updateTextureFromUrl;
908
+
909
+ let lastImageUrl = props.imageUrl;
910
+ let lastValue = JSON.stringify(props.value);
911
+ setInterval(() => {
912
+ if (props.imageUrl !== lastImageUrl) {
913
+ lastImageUrl = props.imageUrl;
914
+ updateTextureFromUrl(props.imageUrl);
915
+ }
916
+ const currentValue = JSON.stringify(props.value);
917
+ if (currentValue !== lastValue) {
918
+ lastValue = currentValue;
919
+ if (props.value && typeof props.value === 'object') {
920
+ azimuthAngle = props.value.azimuth ?? azimuthAngle;
921
+ elevationAngle = props.value.elevation ?? elevationAngle;
922
+ distanceFactor = props.value.distance ?? distanceFactor;
923
+ updatePositions();
924
+ }
925
+ }
926
+ }, 100);
927
+ };
928
+
929
+ initScene();
930
+ })();
931
+ """
932
+
933
+ super().__init__(
934
+ value=value,
935
+ html_template=html_template,
936
+ js_on_load=js_on_load,
937
+ imageUrl=imageUrl,
938
+ **kwargs
939
+ )
940
+
941
+
942
+ # --- UI ---
943
+ with gr.Blocks() as demo:
944
+ gr.LoginButton(value="Option: HuggingFace 'Login' for extra GPU quota +", size="sm")
945
+ # Header
946
+ gr.HTML("""
947
+ <div class="header-container">
948
+ <div class="header-title">๐ŸŽฌ 3D CAMERA ANGLE EDITOR ๐ŸŽฌ</div>
949
+ <div class="header-subtitle">Transform your images with precise camera control using AI!</div>
950
+ <div style="margin-top:12px">
951
+ <span class="stats-badge">๐ŸŽฎ 3D Control</span>
952
+ <span class="stats-badge">๐Ÿ“ 8 Azimuths</span>
953
+ <span class="stats-badge">๐Ÿ“ท 4 Elevations</span>
954
+ <span class="stats-badge">๐Ÿ” 3 Distances</span>
955
+ <span class="stats-badge">โšก Lightning Fast</span>
956
+ </div>
957
+ </div>
958
+ """)
959
+
960
+ gr.HTML('<div class="info-box">๐ŸŽฏ <b>How to use:</b> Upload an image โ†’ Adjust camera angle using 3D viewport or sliders โ†’ Click Generate!</div>')
961
+
962
+ with gr.Row():
963
+ # Left column: Input image and controls
964
+ with gr.Column(scale=1):
965
+ image = gr.Image(label="๐Ÿ“ Input Image", type="pil", height=300)
966
+
967
+ gr.HTML('<div class="info-box">๐ŸŽฎ <b>3D Camera Control</b> - Drag the colored handles to adjust view</div>')
968
+
969
+ camera_3d = CameraControl3D(
970
+ value={"azimuth": 0, "elevation": 0, "distance": 1.0},
971
+ elem_id="camera-3d-control"
972
+ )
973
+
974
+ run_btn = gr.Button("๐Ÿš€ GENERATE!", variant="primary", size="lg")
975
+
976
+ gr.HTML('<div class="info-box">๐ŸŽš๏ธ <b>Slider Controls</b> - Fine-tune your camera settings</div>')
977
+
978
+ azimuth_slider = gr.Slider(
979
+ label="๐Ÿ”„ Azimuth (Horizontal Rotation)",
980
+ minimum=0,
981
+ maximum=315,
982
+ step=45,
983
+ value=0,
984
+ info="0ยฐ=front, 90ยฐ=right, 180ยฐ=back, 270ยฐ=left"
985
+ )
986
+
987
+ elevation_slider = gr.Slider(
988
+ label="๐Ÿ“ Elevation (Vertical Angle)",
989
+ minimum=-30,
990
+ maximum=60,
991
+ step=30,
992
+ value=0,
993
+ info="-30ยฐ=low angle, 0ยฐ=eye level, 60ยฐ=high angle"
994
+ )
995
+
996
+ distance_slider = gr.Slider(
997
+ label="๐Ÿ” Distance",
998
+ minimum=0.6,
999
+ maximum=1.4,
1000
+ step=0.4,
1001
+ value=1.0,
1002
+ info="0.6=close-up, 1.0=medium, 1.4=wide"
1003
+ )
1004
+
1005
+ prompt_preview = gr.Textbox(
1006
+ label="๐Ÿ“ Generated Prompt",
1007
+ value="<sks> front view eye-level shot medium shot",
1008
+ interactive=False
1009
+ )
1010
+
1011
+ # Right column: Output
1012
+ with gr.Column(scale=1):
1013
+ result = gr.Image(label="๐Ÿ–ผ๏ธ Output Image", height=500)
1014
+
1015
+ with gr.Accordion("โš™๏ธ Advanced Settings (Quality Control)", open=False):
1016
+ gr.HTML('<div class="info-box">๐Ÿ’ก <b>Lightning LoRA:</b> Optimized for 4 steps. guidance_scale=1.0 recommended. Max resolution: 768px</div>')
1017
+
1018
+ with gr.Row():
1019
+ num_inference_steps = gr.Slider(
1020
+ label="๐Ÿ”ข Inference Steps",
1021
+ minimum=2,
1022
+ maximum=8,
1023
+ step=1,
1024
+ value=4,
1025
+ info="Lightning LoRA: 4 steps optimal"
1026
+ )
1027
+ guidance_scale = gr.Slider(
1028
+ label="๐ŸŽฏ Guidance Scale",
1029
+ minimum=1.0,
1030
+ maximum=5.0,
1031
+ step=0.5,
1032
+ value=1.0,
1033
+ info="1.0 for Lightning LoRA"
1034
+ )
1035
+
1036
+ with gr.Row():
1037
+ seed = gr.Slider(label="๐ŸŽฒ Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
1038
+ randomize_seed = gr.Checkbox(label="๐Ÿ”€ Randomize Seed", value=True)
1039
+
1040
+ with gr.Row():
1041
+ width = gr.Slider(label="๐Ÿ“ Width", minimum=256, maximum=768, step=8, value=768)
1042
+ height = gr.Slider(label="๐Ÿ“ Height", minimum=256, maximum=768, step=8, value=768)
1043
+
1044
+ # Footer
1045
+ gr.HTML("""
1046
+ <div class="footer-comic">
1047
+ <p style="font-family:'Bangers',cursive;font-size:1.5rem;letter-spacing:2px">๐ŸŽฌ 3D CAMERA ANGLE EDITOR ๐ŸŽฌ</p>
1048
+ <p>Powered by Qwen Image Edit 2511 + Lightning LoRA + Multi-Angles LoRA</p>
1049
+ <p>๐ŸŽฎ 3D Control โ€ข ๐Ÿ“ Precise Angles โ€ข โšก Fast Generation โ€ข ๐ŸŽจ High Quality</p>
1050
+ </div>
1051
+ """)
1052
+
1053
+ # --- Event Handlers ---
1054
+
1055
+ def update_prompt_from_sliders(azimuth, elevation, distance):
1056
+ """Update prompt preview when sliders change."""
1057
+ prompt = build_camera_prompt(azimuth, elevation, distance)
1058
+ return prompt
1059
+
1060
+ def sync_3d_to_sliders(camera_value):
1061
+ """Sync 3D control changes to sliders."""
1062
+ if camera_value and isinstance(camera_value, dict):
1063
+ az = camera_value.get('azimuth', 0)
1064
+ el = camera_value.get('elevation', 0)
1065
+ dist = camera_value.get('distance', 1.0)
1066
+ prompt = build_camera_prompt(az, el, dist)
1067
+ return az, el, dist, prompt
1068
+ return gr.update(), gr.update(), gr.update(), gr.update()
1069
+
1070
+ def sync_sliders_to_3d(azimuth, elevation, distance):
1071
+ """Sync slider changes to 3D control."""
1072
+ return {"azimuth": azimuth, "elevation": elevation, "distance": distance}
1073
+
1074
+ def update_3d_image(image):
1075
+ """Update the 3D component with the uploaded image."""
1076
+ if image is None:
1077
+ return gr.update(imageUrl=None)
1078
+ import base64
1079
+ from io import BytesIO
1080
+ buffered = BytesIO()
1081
+ image.save(buffered, format="PNG")
1082
+ img_str = base64.b64encode(buffered.getvalue()).decode()
1083
+ data_url = f"data:image/png;base64,{img_str}"
1084
+ return gr.update(imageUrl=data_url)
1085
+
1086
+ # Slider -> Prompt preview
1087
+ for slider in [azimuth_slider, elevation_slider, distance_slider]:
1088
+ slider.change(
1089
+ fn=update_prompt_from_sliders,
1090
+ inputs=[azimuth_slider, elevation_slider, distance_slider],
1091
+ outputs=[prompt_preview]
1092
+ )
1093
+
1094
+ # 3D control -> Sliders + Prompt
1095
+ camera_3d.change(
1096
+ fn=sync_3d_to_sliders,
1097
+ inputs=[camera_3d],
1098
+ outputs=[azimuth_slider, elevation_slider, distance_slider, prompt_preview]
1099
+ )
1100
+
1101
+ # Sliders -> 3D control
1102
+ for slider in [azimuth_slider, elevation_slider, distance_slider]:
1103
+ slider.release(
1104
+ fn=sync_sliders_to_3d,
1105
+ inputs=[azimuth_slider, elevation_slider, distance_slider],
1106
+ outputs=[camera_3d]
1107
+ )
1108
+
1109
+ # Generate button
1110
+ run_btn.click(
1111
+ fn=infer_camera_edit,
1112
+ inputs=[image, azimuth_slider, elevation_slider, distance_slider, seed, randomize_seed, guidance_scale, num_inference_steps, height, width],
1113
+ outputs=[result, seed, prompt_preview]
1114
+ )
1115
+
1116
+ # Image upload -> update dimensions AND update 3D preview
1117
+ image.upload(
1118
+ fn=update_dimensions_on_upload,
1119
+ inputs=[image],
1120
+ outputs=[width, height]
1121
+ ).then(
1122
+ fn=update_3d_image,
1123
+ inputs=[image],
1124
+ outputs=[camera_3d]
1125
+ )
1126
+
1127
+ # Also handle image clear
1128
+ image.clear(
1129
+ fn=lambda: gr.update(imageUrl=None),
1130
+ outputs=[camera_3d]
1131
+ )
1132
+
1133
+
1134
+ if __name__ == "__main__":
1135
+ head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
1136
+ demo.launch(head=head, css=COMIC_CSS, theme=gr.themes.Soft())