prithivMLmods commited on
Commit
c867132
·
verified ·
1 Parent(s): 41f82f3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +620 -525
app.py CHANGED
@@ -13,9 +13,6 @@ from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
13
  from gradio.themes import Soft
14
  from gradio.themes.utils import colors, fonts, sizes
15
 
16
- # -----------------------------
17
- # THEME
18
- # -----------------------------
19
  colors.orange_red = colors.Color(
20
  name="orange_red",
21
  c50="#FFF0E5",
@@ -31,7 +28,6 @@ colors.orange_red = colors.Color(
31
  c950="#802200",
32
  )
33
 
34
-
35
  class OrangeRedTheme(Soft):
36
  def __init__(
37
  self,
@@ -41,14 +37,10 @@ class OrangeRedTheme(Soft):
41
  neutral_hue: colors.Color | str = colors.slate,
42
  text_size: sizes.Size | str = sizes.text_lg,
43
  font: fonts.Font | str | Iterable[fonts.Font | str] = (
44
- fonts.GoogleFont("Outfit"),
45
- "Arial",
46
- "sans-serif",
47
  ),
48
  font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
49
- fonts.GoogleFont("IBM Plex Mono"),
50
- "ui-monospace",
51
- "monospace",
52
  ),
53
  ):
54
  super().__init__(
@@ -87,27 +79,21 @@ class OrangeRedTheme(Soft):
87
  block_label_background_fill="*primary_200",
88
  )
89
 
90
-
91
  orange_red_theme = OrangeRedTheme()
92
 
93
- # -----------------------------
94
- # MODEL SETUP
95
- # -----------------------------
96
  MAX_SEED = np.iinfo(np.int32).max
97
 
98
  dtype = torch.bfloat16
99
  device = "cuda" if torch.cuda.is_available() else "cpu"
100
-
101
  pipe = QwenImageEditPlusPipeline.from_pretrained(
102
  "Qwen/Qwen-Image-Edit-2511",
103
  transformer=QwenImageTransformer2DModel.from_pretrained(
104
  "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19",
105
  torch_dtype=dtype,
106
- device_map="cuda",
107
  ),
108
- torch_dtype=dtype,
109
  ).to(device)
110
-
111
  try:
112
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
113
  print("Flash Attention 3 Processor set successfully.")
@@ -117,8 +103,8 @@ except Exception as e:
117
  ADAPTER_SPECS = {
118
  "Multi-Angle-Lighting": {
119
  "repo": "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
120
- "weights": "多角度灯光-251121.safetensors",
121
- "adapter_name": "multi-angle-lighting",
122
  },
123
  }
124
  loaded = False
@@ -131,30 +117,39 @@ AZIMUTH_MAP = {
131
  180: "Rear",
132
  225: "Left Rear",
133
  270: "Left",
134
- 315: "Left Front",
135
  }
136
 
137
  ELEVATION_MAP = {
138
  -90: "Below",
139
  0: "",
140
- 90: "Above",
141
  }
142
 
143
-
144
  def snap_to_nearest(value, options):
 
145
  return min(options, key=lambda x: abs(x - value))
146
 
147
-
148
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
149
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
150
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
151
-
152
  if elevation_snapped == 0:
153
  return f"Light source from the {AZIMUTH_MAP[azimuth_snapped]}"
154
  else:
155
  return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
156
 
157
-
158
  @spaces.GPU
159
  def infer_lighting_edit(
160
  image: Image.Image,
@@ -167,37 +162,29 @@ def infer_lighting_edit(
167
  height: int = 1024,
168
  width: int = 1024,
169
  ):
 
 
 
170
  global loaded
171
  progress = gr.Progress(track_tqdm=True)
172
-
173
  if not loaded:
174
  pipe.load_lora_weights(
175
  ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
176
  weight_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["weights"],
177
- adapter_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"],
178
- )
179
- pipe.set_adapters(
180
- [ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]],
181
- adapter_weights=[1.0],
182
  )
 
183
  loaded = True
184
-
185
  prompt = build_lighting_prompt(azimuth, elevation)
186
  print(f"Generated Prompt: {prompt}")
187
-
188
  if randomize_seed:
189
  seed = random.randint(0, MAX_SEED)
190
  generator = torch.Generator(device=device).manual_seed(seed)
191
-
192
  if image is None:
193
  raise gr.Error("Please upload an image first.")
194
-
195
- pil_image = (
196
- image.convert("RGB")
197
- if isinstance(image, Image.Image)
198
- else Image.open(image).convert("RGB")
199
- )
200
-
201
  result = pipe(
202
  image=[pil_image],
203
  prompt=prompt,
@@ -208,11 +195,10 @@ def infer_lighting_edit(
208
  guidance_scale=guidance_scale,
209
  num_images_per_prompt=1,
210
  ).images[0]
211
-
212
  return result, seed, prompt
213
 
214
-
215
  def update_dimensions_on_upload(image):
 
216
  if image is None:
217
  return 1024, 1024
218
  original_width, original_height = image.size
@@ -228,390 +214,589 @@ def update_dimensions_on_upload(image):
228
  new_height = (new_height // 8) * 8
229
  return new_width, new_height
230
 
231
-
232
- # -----------------------------
233
- # CUSTOM 3D LIGHTING CONTROL
234
- # -----------------------------
235
  class LightingControl3D(gr.HTML):
236
  """
237
- 3D lighting control using Three.js
238
- - Softbox: red body, white light
239
- - Azimuth handle: yellow
240
- - Elevation handle: blue
241
  """
242
-
243
  def __init__(self, value=None, imageUrl=None, **kwargs):
244
  if value is None:
245
  value = {"azimuth": 0, "elevation": 0}
246
-
247
  html_template = """
248
- <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden;">
249
- <div id="prompt-overlay" style="position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 8px 16px; border-radius: 8px; font-family: monospace; font-size: 12px; color: #00ff88; white-space: nowrap; z-index: 10;"></div>
 
 
 
 
 
 
 
 
 
 
250
  </div>
251
  """
252
-
253
  js_on_load = """
254
  (() => {
255
  const wrapper = element.querySelector('#lighting-control-wrapper');
256
  const promptOverlay = element.querySelector('#prompt-overlay');
257
-
 
258
  const initScene = () => {
259
  if (typeof THREE === 'undefined') {
260
  setTimeout(initScene, 100);
261
  return;
262
  }
263
-
264
  // Scene setup
265
  const scene = new THREE.Scene();
266
- scene.background = new THREE.Color(0x1a1a1a);
267
-
268
- const camera = new THREE.PerspectiveCamera(
269
- 50,
270
- wrapper.clientWidth / wrapper.clientHeight,
271
- 0.1,
272
- 1000
273
- );
274
  camera.position.set(4.5, 3, 4.5);
275
  camera.lookAt(0, 0.75, 0);
276
-
277
  const renderer = new THREE.WebGLRenderer({ antialias: true });
278
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
279
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
280
  renderer.shadowMap.enabled = true;
281
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
282
- wrapper.insertBefore(renderer.domElement, promptOverlay);
283
-
284
- // Lighting
285
- scene.add(new THREE.AmbientLight(0xffffff, 0.15));
286
-
287
- // Ground plane for shadows
288
- const ground = new THREE.Mesh(
289
- new THREE.PlaneGeometry(10, 10),
290
- new THREE.ShadowMaterial({ opacity: 0.3 })
291
- );
 
 
 
 
 
 
292
  ground.rotation.x = -Math.PI / 2;
293
  ground.position.y = 0;
294
  ground.receiveShadow = true;
295
  scene.add(ground);
296
-
297
- // Grid
298
- scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
299
-
 
 
 
 
 
 
 
 
 
 
 
 
300
  // Constants
301
  const CENTER = new THREE.Vector3(0, 0.75, 0);
302
  const BASE_DISTANCE = 2.5;
303
  const AZIMUTH_RADIUS = 2.4;
304
  const ELEVATION_RADIUS = 1.8;
305
-
306
  // State
307
  let azimuthAngle = props.value?.azimuth || 0;
308
  let elevationAngle = props.value?.elevation || 0;
309
-
310
  // Mappings
311
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
312
  const elevationSteps = [-90, 0, 90];
313
  const azimuthNames = {
314
- 0: 'Front',
315
- 45: 'Right Front',
316
- 90: 'Right',
317
- 135: 'Right Rear',
318
- 180: 'Rear',
319
- 225: 'Left Rear',
320
- 270: 'Left',
321
- 315: 'Left Front'
322
  };
323
  const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
324
-
325
  function snapToNearest(value, steps) {
326
- return steps.reduce((prev, curr) =>
327
- Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
328
- );
329
  }
330
-
331
- // Placeholder texture for target image plane
332
  function createPlaceholderTexture() {
333
  const canvas = document.createElement('canvas');
334
  canvas.width = 256;
335
  canvas.height = 256;
336
  const ctx = canvas.getContext('2d');
337
- ctx.fillStyle = '#3a3a4a';
 
 
 
 
 
338
  ctx.fillRect(0, 0, 256, 256);
339
- ctx.fillStyle = '#ffcc99';
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  ctx.beginPath();
341
- ctx.arc(128, 128, 80, 0, Math.PI * 2);
342
  ctx.fill();
343
- ctx.fillStyle = '#333';
 
344
  ctx.beginPath();
345
- ctx.arc(100, 110, 10, 0, Math.PI * 2);
346
- ctx.arc(156, 110, 10, 0, Math.PI * 2);
347
  ctx.fill();
348
- ctx.strokeStyle = '#333';
349
- ctx.lineWidth = 3;
 
350
  ctx.beginPath();
351
- ctx.arc(128, 130, 35, 0.2, Math.PI - 0.2);
352
- ctx.stroke();
 
 
 
 
 
 
 
353
  return new THREE.CanvasTexture(canvas);
354
  }
355
-
356
  // Target image plane
357
  let currentTexture = createPlaceholderTexture();
358
- const planeMaterial = new THREE.MeshStandardMaterial({
359
- map: currentTexture,
360
- side: THREE.DoubleSide,
361
- roughness: 0.5,
362
- metalness: 0
363
  });
364
- let targetPlane = new THREE.Mesh(
365
- new THREE.PlaneGeometry(1.2, 1.2),
366
- planeMaterial
367
- );
368
  targetPlane.position.copy(CENTER);
369
  targetPlane.receiveShadow = true;
 
370
  scene.add(targetPlane);
371
-
372
- // Update texture from image URL
 
 
 
 
 
 
 
 
 
 
 
373
  function updateTextureFromUrl(url) {
374
  if (!url) {
375
- // Reset to placeholder
376
  planeMaterial.map = createPlaceholderTexture();
377
  planeMaterial.needsUpdate = true;
378
-
379
  scene.remove(targetPlane);
380
- targetPlane = new THREE.Mesh(
381
- new THREE.PlaneGeometry(1.2, 1.2),
382
- planeMaterial
383
- );
384
  targetPlane.position.copy(CENTER);
385
  targetPlane.receiveShadow = true;
 
386
  scene.add(targetPlane);
 
 
 
 
387
  return;
388
  }
389
-
390
  const loader = new THREE.TextureLoader();
391
  loader.crossOrigin = 'anonymous';
392
- loader.load(
393
- url,
394
- (texture) => {
395
- texture.minFilter = THREE.LinearFilter;
396
- texture.magFilter = THREE.LinearFilter;
397
- planeMaterial.map = texture;
398
- planeMaterial.needsUpdate = true;
399
-
400
- const img = texture.image;
401
- if (img && img.width && img.height) {
402
- const aspect = img.width / img.height;
403
- const maxSize = 1.5;
404
- let planeWidth, planeHeight;
405
- if (aspect > 1) {
406
- planeWidth = maxSize;
407
- planeHeight = maxSize / aspect;
408
- } else {
409
- planeHeight = maxSize;
410
- planeWidth = maxSize * aspect;
411
- }
412
- scene.remove(targetPlane);
413
- targetPlane = new THREE.Mesh(
414
- new THREE.PlaneGeometry(planeWidth, planeHeight),
415
- planeMaterial
416
- );
417
- targetPlane.position.copy(CENTER);
418
- targetPlane.receiveShadow = true;
419
- scene.add(targetPlane);
420
  }
421
- },
422
- undefined,
423
- (err) => {
424
- console.error('Failed to load texture:', err);
 
 
 
 
 
 
 
 
 
 
 
425
  }
426
- );
 
 
427
  }
428
-
429
- // Initial image, if provided
430
  if (props.imageUrl) {
431
  updateTextureFromUrl(props.imageUrl);
432
  }
433
-
434
- // --------------------------
435
- // RED SOFTBOX STUDIO LIGHT
436
- // --------------------------
437
  const lightGroup = new THREE.Group();
438
-
439
- // Softbox body (red housing)
440
- const bodyGeom = new THREE.BoxGeometry(0.9, 0.7, 0.2);
441
- const bodyMat = new THREE.MeshStandardMaterial({
442
- color: 0xcc2233,
443
- roughness: 0.6,
444
- metalness: 0.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  });
446
- const bodyMesh = new THREE.Mesh(bodyGeom, bodyMat);
447
- lightGroup.add(bodyMesh);
448
-
449
- // Front panel (white light)
450
- const panelGeom = new THREE.PlaneGeometry(0.7, 0.5);
451
- const panelMat = new THREE.MeshStandardMaterial({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  color: 0xffffff,
453
  emissive: 0xffffff,
454
- emissiveIntensity: 3.0,
455
- side: THREE.FrontSide,
456
- roughness: 0.3,
457
- metalness: 0.0
458
- });
459
- const panelMesh = new THREE.Mesh(panelGeom, panelMat);
460
- panelMesh.position.z = 0.11;
461
- lightGroup.add(panelMesh);
462
-
463
- // Simple yoke/arm under the softbox
464
- const armGeom = new THREE.CylinderGeometry(0.03, 0.03, 0.4, 16);
465
- const armMat = new THREE.MeshStandardMaterial({
466
- color: 0x555555,
467
- roughness: 0.5,
468
- metalness: 0.5
469
  });
470
- const armMesh = new THREE.Mesh(armGeom, armMat);
471
- armMesh.position.set(0, -0.6, 0);
472
- lightGroup.add(armMesh);
473
-
474
- // Spotlight (actual light) - white
475
- const spotLight = new THREE.SpotLight(
476
- 0xffffff,
477
- 10,
478
- 10,
479
- Math.PI / 3,
480
- 1,
481
- 1
482
  );
483
- // Place light slightly in front of panel
484
- spotLight.position.set(0, 0, 0.15);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  spotLight.castShadow = true;
486
  spotLight.shadow.mapSize.width = 1024;
487
  spotLight.shadow.mapSize.height = 1024;
488
  spotLight.shadow.camera.near = 0.5;
489
- spotLight.shadow.camera.far = 500;
490
- spotLight.shadow.bias = -0.005;
491
  lightGroup.add(spotLight);
492
-
493
  const lightTarget = new THREE.Object3D();
494
  lightTarget.position.copy(CENTER);
495
  scene.add(lightTarget);
496
  spotLight.target = lightTarget;
497
-
 
 
 
 
 
498
  scene.add(lightGroup);
499
-
500
- // --------------------------
501
- // CONTROLLERS
502
- // --------------------------
503
-
504
- // AZIMUTH RING & HANDLE (YELLOW)
505
  const azimuthRing = new THREE.Mesh(
506
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64),
507
- new THREE.MeshStandardMaterial({
508
- color: 0xffd54f, // yellow
509
- emissive: 0xffd54f,
510
- emissiveIntensity: 0.25
 
 
511
  })
512
  );
513
  azimuthRing.rotation.x = Math.PI / 2;
514
  azimuthRing.position.y = 0.05;
515
  scene.add(azimuthRing);
516
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  const azimuthHandle = new THREE.Mesh(
518
- new THREE.SphereGeometry(0.18, 16, 16),
519
- new THREE.MeshStandardMaterial({
520
- color: 0xffd54f,
521
- emissive: 0xffd54f,
522
- emissiveIntensity: 0.6
 
 
523
  })
524
  );
525
  azimuthHandle.userData.type = 'azimuth';
526
  scene.add(azimuthHandle);
527
-
528
- // ELEVATION ARC & HANDLE (BLUE)
 
 
529
  const arcPoints = [];
530
- for (let i = 0; i <= 32; i++) {
531
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32));
532
- arcPoints.push(
533
- new THREE.Vector3(
534
- -0.8,
535
- ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
536
- ELEVATION_RADIUS * Math.cos(angle)
537
- )
538
- );
539
  }
540
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
541
  const elevationArc = new THREE.Mesh(
542
- new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false),
543
- new THREE.MeshStandardMaterial({
544
- color: 0x2196f3, // blue
545
- emissive: 0x2196f3,
546
- emissiveIntensity: 0.25
 
 
547
  })
548
  );
549
  scene.add(elevationArc);
550
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  const elevationHandle = new THREE.Mesh(
552
- new THREE.SphereGeometry(0.18, 16, 16),
553
- new THREE.MeshStandardMaterial({
554
- color: 0x2196f3,
555
- emissive: 0x2196f3,
556
- emissiveIntensity: 0.6
 
 
557
  })
558
  );
559
  elevationHandle.userData.type = 'elevation';
560
  scene.add(elevationHandle);
561
-
562
- // RESET BUTTON (inside 3D control)
 
 
563
  const refreshBtn = document.createElement('button');
564
- refreshBtn.textContent = 'Reset Light';
565
- refreshBtn.style.position = 'absolute';
566
- refreshBtn.style.top = '12px';
567
- refreshBtn.style.right = '12px';
568
- refreshBtn.style.background = 'rgba(0,0,0,0.7)';
569
- refreshBtn.style.color = '#ffffff';
570
- refreshBtn.style.padding = '6px 12px';
571
- refreshBtn.style.borderRadius = '6px';
572
- refreshBtn.style.cursor = 'pointer';
573
- refreshBtn.style.zIndex = '10';
574
- refreshBtn.style.fontSize = '12px';
575
- refreshBtn.style.border = '1px solid rgba(255,255,255,0.2)';
576
- refreshBtn.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  wrapper.appendChild(refreshBtn);
578
-
579
  refreshBtn.addEventListener('click', () => {
580
- azimuthAngle = 0;
581
- elevationAngle = 0;
582
- updatePositions();
583
- updatePropsAndTrigger();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  });
585
-
586
- // --------------------------
587
- // POSITION / PROMPT UPDATE
588
- // --------------------------
589
  function updatePositions() {
590
  const distance = BASE_DISTANCE;
591
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
592
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
593
-
594
  const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
595
  const lightY = distance * Math.sin(elRad) + CENTER.y;
596
  const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
597
-
598
  lightGroup.position.set(lightX, lightY, lightZ);
599
  lightGroup.lookAt(CENTER);
600
-
601
  azimuthHandle.position.set(
602
- AZIMUTH_RADIUS * Math.sin(azRad),
603
- 0.05,
604
  AZIMUTH_RADIUS * Math.cos(azRad)
605
  );
606
  elevationHandle.position.set(
607
- -0.8,
608
- ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
609
  ELEVATION_RADIUS * Math.cos(elRad)
610
  );
611
-
 
612
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
613
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
614
- let prompt = 'Light source from';
615
  if (elSnap !== 0) {
616
  prompt += ' ' + elevationNames[String(elSnap)];
617
  } else {
@@ -619,80 +804,65 @@ class LightingControl3D(gr.HTML):
619
  }
620
  promptOverlay.textContent = prompt;
621
  }
622
-
623
  function updatePropsAndTrigger() {
624
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
625
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
626
-
627
  props.value = { azimuth: azSnap, elevation: elSnap };
628
  trigger('change', props.value);
629
  }
630
-
631
- // --------------------------
632
- // INTERACTION (MOUSE / TOUCH)
633
- // --------------------------
634
  const raycaster = new THREE.Raycaster();
635
  const mouse = new THREE.Vector2();
636
  let isDragging = false;
637
  let dragTarget = null;
638
  let dragStartMouse = new THREE.Vector2();
639
  const intersection = new THREE.Vector3();
640
-
641
  const canvas = renderer.domElement;
642
-
643
  canvas.addEventListener('mousedown', (e) => {
644
  const rect = canvas.getBoundingClientRect();
645
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
646
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
647
-
648
  raycaster.setFromCamera(mouse, camera);
649
- const intersects = raycaster.intersectObjects([
650
- azimuthHandle,
651
- elevationHandle
652
- ]);
653
-
654
  if (intersects.length > 0) {
655
  isDragging = true;
656
  dragTarget = intersects[0].object;
657
- dragTarget.material.emissiveIntensity = 1.0;
658
  dragTarget.scale.setScalar(1.3);
659
  dragStartMouse.copy(mouse);
660
  canvas.style.cursor = 'grabbing';
661
  }
662
  });
663
-
664
  canvas.addEventListener('mousemove', (e) => {
665
  const rect = canvas.getBoundingClientRect();
666
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
667
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
668
-
669
  if (isDragging && dragTarget) {
670
  raycaster.setFromCamera(mouse, camera);
671
-
672
  if (dragTarget.userData.type === 'azimuth') {
673
- const plane = new THREE.Plane(
674
- new THREE.Vector3(0, 1, 0),
675
- -0.05
676
- );
677
  if (raycaster.ray.intersectPlane(plane, intersection)) {
678
- azimuthAngle = THREE.MathUtils.radToDeg(
679
- Math.atan2(intersection.x, intersection.z)
680
- );
681
  if (azimuthAngle < 0) azimuthAngle += 360;
682
  }
683
  } else if (dragTarget.userData.type === 'elevation') {
684
- const plane = new THREE.Plane(
685
- new THREE.Vector3(1, 0, 0),
686
- -0.8
687
- );
688
  if (raycaster.ray.intersectPlane(plane, intersection)) {
689
  const relY = intersection.y - CENTER.y;
690
  const relZ = intersection.z;
691
  elevationAngle = THREE.MathUtils.clamp(
692
- THREE.MathUtils.radToDeg(
693
- Math.atan2(relY, relZ)
694
- ),
695
- -90,
696
  90
697
  );
698
  }
@@ -700,49 +870,46 @@ class LightingControl3D(gr.HTML):
700
  updatePositions();
701
  } else {
702
  raycaster.setFromCamera(mouse, camera);
703
- const intersects = raycaster.intersectObjects([
704
- azimuthHandle,
705
- elevationHandle
706
- ]);
707
- [azimuthHandle, elevationHandle].forEach((h) => {
708
  h.material.emissiveIntensity = 0.6;
709
  h.scale.setScalar(1);
710
  });
711
  if (intersects.length > 0) {
712
  intersects[0].object.material.emissiveIntensity = 0.9;
713
- intersects[0].object.scale.setScalar(1.1);
714
  canvas.style.cursor = 'grab';
715
  } else {
716
  canvas.style.cursor = 'default';
717
  }
718
  }
719
  });
720
-
721
  const onMouseUp = () => {
722
  if (dragTarget) {
723
  dragTarget.material.emissiveIntensity = 0.6;
724
  dragTarget.scale.setScalar(1);
725
-
 
726
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
727
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
728
-
729
- const startAz = azimuthAngle;
730
- const startEl = elevationAngle;
731
  const startTime = Date.now();
732
-
733
  function animateSnap() {
734
  const t = Math.min((Date.now() - startTime) / 200, 1);
735
  const ease = 1 - Math.pow(1 - t, 3);
736
-
737
  let azDiff = targetAz - startAz;
738
  if (azDiff > 180) azDiff -= 360;
739
  if (azDiff < -180) azDiff += 360;
740
  azimuthAngle = startAz + azDiff * ease;
741
  if (azimuthAngle < 0) azimuthAngle += 360;
742
  if (azimuthAngle >= 360) azimuthAngle -= 360;
743
-
744
  elevationAngle = startEl + (targetEl - startEl) * ease;
745
-
746
  updatePositions();
747
  if (t < 1) requestAnimationFrame(animateSnap);
748
  else updatePropsAndTrigger();
@@ -753,119 +920,90 @@ class LightingControl3D(gr.HTML):
753
  dragTarget = null;
754
  canvas.style.cursor = 'default';
755
  };
756
-
757
  canvas.addEventListener('mouseup', onMouseUp);
758
  canvas.addEventListener('mouseleave', onMouseUp);
759
-
760
- // Touch support
761
- canvas.addEventListener(
762
- 'touchstart',
763
- (e) => {
764
- e.preventDefault();
765
- const touch = e.touches[0];
766
- const rect = canvas.getBoundingClientRect();
767
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
768
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
769
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  raycaster.setFromCamera(mouse, camera);
771
- const intersects = raycaster.intersectObjects([
772
- azimuthHandle,
773
- elevationHandle
774
- ]);
775
-
776
- if (intersects.length > 0) {
777
- isDragging = true;
778
- dragTarget = intersects[0].object;
779
- dragTarget.material.emissiveIntensity = 1.0;
780
- dragTarget.scale.setScalar(1.3);
781
- dragStartMouse.copy(mouse);
782
- }
783
- },
784
- { passive: false }
785
- );
786
-
787
- canvas.addEventListener(
788
- 'touchmove',
789
- (e) => {
790
- e.preventDefault();
791
- const touch = e.touches[0];
792
- const rect = canvas.getBoundingClientRect();
793
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
794
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
795
-
796
- if (isDragging && dragTarget) {
797
- raycaster.setFromCamera(mouse, camera);
798
-
799
- if (dragTarget.userData.type === 'azimuth') {
800
- const plane = new THREE.Plane(
801
- new THREE.Vector3(0, 1, 0),
802
- -0.05
803
- );
804
- if (raycaster.ray.intersectPlane(plane, intersection)) {
805
- azimuthAngle = THREE.MathUtils.radToDeg(
806
- Math.atan2(intersection.x, intersection.z)
807
- );
808
- if (azimuthAngle < 0) azimuthAngle += 360;
809
- }
810
- } else if (dragTarget.userData.type === 'elevation') {
811
- const plane = new THREE.Plane(
812
- new THREE.Vector3(1, 0, 0),
813
- -0.8
814
  );
815
- if (raycaster.ray.intersectPlane(plane, intersection)) {
816
- const relY = intersection.y - CENTER.y;
817
- const relZ = intersection.z;
818
- elevationAngle = THREE.MathUtils.clamp(
819
- THREE.MathUtils.radToDeg(
820
- Math.atan2(relY, relZ)
821
- ),
822
- -90,
823
- 90
824
- );
825
- }
826
  }
827
- updatePositions();
828
  }
829
- },
830
- { passive: false }
831
- );
832
-
833
- canvas.addEventListener(
834
- 'touchend',
835
- (e) => {
836
- e.preventDefault();
837
- onMouseUp();
838
- },
839
- { passive: false }
840
- );
841
-
842
- canvas.addEventListener(
843
- 'touchcancel',
844
- (e) => {
845
- e.preventDefault();
846
- onMouseUp();
847
- },
848
- { passive: false }
849
- );
850
-
851
  // Initial update
852
  updatePositions();
853
-
854
  // Render loop
855
  function render() {
856
  requestAnimationFrame(render);
857
  renderer.render(scene, camera);
858
  }
859
  render();
860
-
861
- // Resize handling
862
  new ResizeObserver(() => {
863
  camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
864
  camera.updateProjectionMatrix();
865
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
866
  }).observe(wrapper);
867
-
868
- // External updates
869
  wrapper._updateFromProps = (newVal) => {
870
  if (newVal && typeof newVal === 'object') {
871
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
@@ -873,10 +1011,10 @@ class LightingControl3D(gr.HTML):
873
  updatePositions();
874
  }
875
  };
876
-
877
  wrapper._updateTexture = updateTextureFromUrl;
878
-
879
- // Watch for prop changes (imageUrl and value)
880
  let lastImageUrl = props.imageUrl;
881
  let lastValue = JSON.stringify(props.value);
882
  setInterval(() => {
@@ -895,73 +1033,62 @@ class LightingControl3D(gr.HTML):
895
  }
896
  }, 100);
897
  };
898
-
899
  initScene();
900
  })();
901
  """
902
-
903
  super().__init__(
904
  value=value,
905
  html_template=html_template,
906
  js_on_load=js_on_load,
907
  imageUrl=imageUrl,
908
- **kwargs,
909
  )
910
 
911
-
912
- # -----------------------------
913
- # GRADIO UI
914
- # -----------------------------
915
- css = """
916
  #col-container { max-width: 1200px; margin: 0 auto; }
917
  .dark .progress-text { color: white !important; }
918
  #lighting-3d-control { min-height: 450px; }
919
  .slider-row { display: flex; gap: 10px; align-items: center; }
920
  #main-title h1 {font-size: 2.4em !important;}
921
- """
922
 
923
  with gr.Blocks(css=css) as demo:
924
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
925
- gr.Markdown(
926
- "Control lighting directions using the **3D viewport** or **sliders**. "
927
- "Uses the [Multi-Angle-Lighting](https://huggingface.co/dx8152/Qwen-Edit-2509-Multi-Angle-Lighting) LoRA "
928
- "with [Rapid-AIO-V19](https://huggingface.co/prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19)."
929
- )
930
-
931
  with gr.Row():
932
  with gr.Column(scale=1):
933
  image = gr.Image(label="Input Image", type="pil", height=300)
934
-
935
  gr.Markdown("### 3D Lighting Control")
936
- gr.Markdown(
937
- "*Drag the colored handles: Yellow = Azimuth (direction), Blue = Elevation (height)*"
938
- )
939
-
940
  lighting_3d = LightingControl3D(
941
  value={"azimuth": 0, "elevation": 0},
942
- elem_id="lighting-3d-control",
943
  )
944
-
945
  run_btn = gr.Button("Generate Image", variant="primary", size="lg")
946
-
947
  gr.Markdown("### Slider Controls")
948
-
949
  azimuth_slider = gr.Slider(
950
  label="Azimuth (Horizontal Rotation)",
951
  minimum=0,
952
  maximum=315,
953
  step=45,
954
  value=0,
955
- info="0°=front, 90°=right, 180°=rear, 270°=left",
956
  )
957
-
958
  elevation_slider = gr.Slider(
959
  label="Elevation (Vertical Angle)",
960
  minimum=-90,
961
  maximum=90,
962
  step=90,
963
  value=0,
964
- info="-90°=from below, 0°=horizontal, 90°=from above",
965
  )
966
 
967
  with gr.Row():
@@ -971,123 +1098,91 @@ with gr.Blocks(css=css) as demo:
971
  interactive=True,
972
  lines=1,
973
  )
974
-
975
  with gr.Column(scale=1):
976
  result = gr.Image(label="Output Image", height=500)
977
-
978
  with gr.Accordion("Advanced Settings", open=False):
979
- seed = gr.Slider(
980
- label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0
981
- )
982
  randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
983
- guidance_scale = gr.Slider(
984
- label="Guidance Scale",
985
- minimum=1.0,
986
- maximum=10.0,
987
- step=0.1,
988
- value=1.0,
989
- )
990
- num_inference_steps = gr.Slider(
991
- label="Inference Steps",
992
- minimum=1,
993
- maximum=20,
994
- step=1,
995
- value=4,
996
- )
997
- height = gr.Slider(
998
- label="Height", minimum=256, maximum=2048, step=8, value=1024
999
- )
1000
- width = gr.Slider(
1001
- label="Width", minimum=256, maximum=2048, step=8, value=1024
1002
- )
1003
-
1004
- # Helper callbacks
1005
  def update_prompt_from_sliders(azimuth, elevation):
1006
- return build_lighting_prompt(azimuth, elevation)
1007
-
 
 
1008
  def sync_3d_to_sliders(lighting_value):
 
1009
  if lighting_value and isinstance(lighting_value, dict):
1010
- az = lighting_value.get("azimuth", 0)
1011
- el = lighting_value.get("elevation", 0)
1012
  prompt = build_lighting_prompt(az, el)
1013
  return az, el, prompt
1014
  return gr.update(), gr.update(), gr.update()
1015
-
1016
  def sync_sliders_to_3d(azimuth, elevation):
 
1017
  return {"azimuth": azimuth, "elevation": elevation}
1018
-
1019
  def update_3d_image(image):
 
1020
  if image is None:
1021
  return gr.update(imageUrl=None)
 
1022
  import base64
1023
  from io import BytesIO
1024
-
1025
  buffered = BytesIO()
1026
  image.save(buffered, format="PNG")
1027
  img_str = base64.b64encode(buffered.getvalue()).decode()
1028
  data_url = f"data:image/png;base64,{img_str}"
1029
  return gr.update(imageUrl=data_url)
1030
-
1031
- # Events
1032
  for slider in [azimuth_slider, elevation_slider]:
1033
  slider.change(
1034
  fn=update_prompt_from_sliders,
1035
  inputs=[azimuth_slider, elevation_slider],
1036
- outputs=[prompt_preview],
1037
  )
1038
-
1039
  lighting_3d.change(
1040
  fn=sync_3d_to_sliders,
1041
  inputs=[lighting_3d],
1042
- outputs=[azimuth_slider, elevation_slider, prompt_preview],
1043
  )
1044
-
1045
  for slider in [azimuth_slider, elevation_slider]:
1046
  slider.release(
1047
  fn=sync_sliders_to_3d,
1048
  inputs=[azimuth_slider, elevation_slider],
1049
- outputs=[lighting_3d],
1050
  )
1051
-
1052
  run_btn.click(
1053
  fn=infer_lighting_edit,
1054
- inputs=[
1055
- image,
1056
- azimuth_slider,
1057
- elevation_slider,
1058
- seed,
1059
- randomize_seed,
1060
- guidance_scale,
1061
- num_inference_steps,
1062
- height,
1063
- width,
1064
- ],
1065
- outputs=[result, seed, prompt_preview],
1066
  )
1067
-
1068
  image.upload(
1069
  fn=update_dimensions_on_upload,
1070
  inputs=[image],
1071
- outputs=[width, height],
1072
  ).then(
1073
  fn=update_3d_image,
1074
  inputs=[image],
1075
- outputs=[lighting_3d],
1076
  )
1077
-
1078
  image.clear(
1079
  fn=lambda: gr.update(imageUrl=None),
1080
- outputs=[lighting_3d],
1081
  )
1082
-
1083
  if __name__ == "__main__":
1084
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
1085
- css = ".fillable{max-width: 1200px !important}"
1086
- demo.launch(
1087
- head=head,
1088
- css=css,
1089
- theme=orange_red_theme,
1090
- mcp_server=True,
1091
- ssr_mode=False,
1092
- show_error=True,
1093
- )
 
13
  from gradio.themes import Soft
14
  from gradio.themes.utils import colors, fonts, sizes
15
 
 
 
 
16
  colors.orange_red = colors.Color(
17
  name="orange_red",
18
  c50="#FFF0E5",
 
28
  c950="#802200",
29
  )
30
 
 
31
  class OrangeRedTheme(Soft):
32
  def __init__(
33
  self,
 
37
  neutral_hue: colors.Color | str = colors.slate,
38
  text_size: sizes.Size | str = sizes.text_lg,
39
  font: fonts.Font | str | Iterable[fonts.Font | str] = (
40
+ fonts.GoogleFont("Outfit"), "Arial", "sans-serif",
 
 
41
  ),
42
  font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
43
+ fonts.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace",
 
 
44
  ),
45
  ):
46
  super().__init__(
 
79
  block_label_background_fill="*primary_200",
80
  )
81
 
 
82
  orange_red_theme = OrangeRedTheme()
83
 
 
 
 
84
  MAX_SEED = np.iinfo(np.int32).max
85
 
86
  dtype = torch.bfloat16
87
  device = "cuda" if torch.cuda.is_available() else "cpu"
 
88
  pipe = QwenImageEditPlusPipeline.from_pretrained(
89
  "Qwen/Qwen-Image-Edit-2511",
90
  transformer=QwenImageTransformer2DModel.from_pretrained(
91
  "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19",
92
  torch_dtype=dtype,
93
+ device_map='cuda'
94
  ),
95
+ torch_dtype=dtype
96
  ).to(device)
 
97
  try:
98
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
99
  print("Flash Attention 3 Processor set successfully.")
 
103
  ADAPTER_SPECS = {
104
  "Multi-Angle-Lighting": {
105
  "repo": "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
106
+ "weights": "多角度灯光-251116.safetensors",
107
+ "adapter_name": "multi-angle-lighting"
108
  },
109
  }
110
  loaded = False
 
117
  180: "Rear",
118
  225: "Left Rear",
119
  270: "Left",
120
+ 315: "Left Front"
121
  }
122
 
123
  ELEVATION_MAP = {
124
  -90: "Below",
125
  0: "",
126
+ 90: "Above"
127
  }
128
 
 
129
  def snap_to_nearest(value, options):
130
+ """Snap a value to the nearest option in a list."""
131
  return min(options, key=lambda x: abs(x - value))
132
 
 
133
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
134
+ """
135
+ Build a lighting prompt from azimuth and elevation values.
136
+
137
+ Args:
138
+ azimuth: Horizontal rotation in degrees (0-360)
139
+ elevation: Vertical angle in degrees (-90 to 90)
140
+
141
+ Returns:
142
+ Formatted prompt string for the LoRA
143
+ """
144
+ # Snap to nearest valid values
145
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
146
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
147
+
148
  if elevation_snapped == 0:
149
  return f"Light source from the {AZIMUTH_MAP[azimuth_snapped]}"
150
  else:
151
  return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
152
 
 
153
  @spaces.GPU
154
  def infer_lighting_edit(
155
  image: Image.Image,
 
162
  height: int = 1024,
163
  width: int = 1024,
164
  ):
165
+ """
166
+ Edit the lighting of an image using Qwen Image Edit 2511 with multi-angle lighting LoRA.
167
+ """
168
  global loaded
169
  progress = gr.Progress(track_tqdm=True)
170
+
171
  if not loaded:
172
  pipe.load_lora_weights(
173
  ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
174
  weight_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["weights"],
175
+ adapter_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]
 
 
 
 
176
  )
177
+ pipe.set_adapters([ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]], adapter_weights=[1.0])
178
  loaded = True
179
+
180
  prompt = build_lighting_prompt(azimuth, elevation)
181
  print(f"Generated Prompt: {prompt}")
 
182
  if randomize_seed:
183
  seed = random.randint(0, MAX_SEED)
184
  generator = torch.Generator(device=device).manual_seed(seed)
 
185
  if image is None:
186
  raise gr.Error("Please upload an image first.")
187
+ pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
 
 
 
 
 
 
188
  result = pipe(
189
  image=[pil_image],
190
  prompt=prompt,
 
195
  guidance_scale=guidance_scale,
196
  num_images_per_prompt=1,
197
  ).images[0]
 
198
  return result, seed, prompt
199
 
 
200
  def update_dimensions_on_upload(image):
201
+ """Compute recommended dimensions preserving aspect ratio."""
202
  if image is None:
203
  return 1024, 1024
204
  original_width, original_height = image.size
 
214
  new_height = (new_height // 8) * 8
215
  return new_width, new_height
216
 
 
 
 
 
217
  class LightingControl3D(gr.HTML):
218
  """
219
+ A 3D lighting control component using Three.js.
220
+ Outputs: { azimuth: number, elevation: number }
221
+ Accepts imageUrl prop to display user's uploaded image on the plane.
 
222
  """
 
223
  def __init__(self, value=None, imageUrl=None, **kwargs):
224
  if value is None:
225
  value = {"azimuth": 0, "elevation": 0}
226
+
227
  html_template = """
228
+ <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: linear-gradient(180deg, #0d1117 0%, #161b22 50%, #21262d 100%); border-radius: 12px; overflow: hidden; border: 1px solid #30363d;">
229
+ <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: 8px; font-family: 'Courier New', monospace; font-size: 13px; color: #58a6ff; white-space: nowrap; z-index: 10; border: 1px solid #30363d; box-shadow: 0 4px 12px rgba(0,0,0,0.4);"></div>
230
+ <div id="controls-legend" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.75); padding: 8px 12px; border-radius: 6px; font-family: sans-serif; font-size: 11px; color: #c9d1d9; z-index: 10; border: 1px solid #30363d;">
231
+ <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
232
+ <span style="width: 12px; height: 12px; background: #ffd700; border-radius: 50%; display: inline-block;"></span>
233
+ <span>Azimuth (Direction)</span>
234
+ </div>
235
+ <div style="display: flex; align-items: center; gap: 6px;">
236
+ <span style="width: 12px; height: 12px; background: #4dabf7; border-radius: 50%; display: inline-block;"></span>
237
+ <span>Elevation (Height)</span>
238
+ </div>
239
+ </div>
240
  </div>
241
  """
242
+
243
  js_on_load = """
244
  (() => {
245
  const wrapper = element.querySelector('#lighting-control-wrapper');
246
  const promptOverlay = element.querySelector('#prompt-overlay');
247
+
248
+ // Wait for THREE to load
249
  const initScene = () => {
250
  if (typeof THREE === 'undefined') {
251
  setTimeout(initScene, 100);
252
  return;
253
  }
254
+
255
  // Scene setup
256
  const scene = new THREE.Scene();
257
+ scene.background = new THREE.Color(0x0d1117);
258
+
259
+ // Add fog for depth
260
+ scene.fog = new THREE.Fog(0x0d1117, 8, 20);
261
+
262
+ const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
 
 
263
  camera.position.set(4.5, 3, 4.5);
264
  camera.lookAt(0, 0.75, 0);
265
+
266
  const renderer = new THREE.WebGLRenderer({ antialias: true });
267
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
268
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
269
  renderer.shadowMap.enabled = true;
270
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
271
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
272
+ renderer.toneMappingExposure = 1.2;
273
+ wrapper.insertBefore(renderer.domElement, wrapper.firstChild);
274
+
275
+ // Ambient lighting
276
+ const ambientLight = new THREE.AmbientLight(0x404050, 0.3);
277
+ scene.add(ambientLight);
278
+
279
+ // Ground plane with better material
280
+ const groundGeometry = new THREE.PlaneGeometry(12, 12);
281
+ const groundMaterial = new THREE.MeshStandardMaterial({
282
+ color: 0x1a1a2e,
283
+ roughness: 0.8,
284
+ metalness: 0.2
285
+ });
286
+ const ground = new THREE.Mesh(groundGeometry, groundMaterial);
287
  ground.rotation.x = -Math.PI / 2;
288
  ground.position.y = 0;
289
  ground.receiveShadow = true;
290
  scene.add(ground);
291
+
292
+ // Shadow-only plane
293
+ const shadowPlane = new THREE.Mesh(
294
+ new THREE.PlaneGeometry(12, 12),
295
+ new THREE.ShadowMaterial({ opacity: 0.4 })
296
+ );
297
+ shadowPlane.rotation.x = -Math.PI / 2;
298
+ shadowPlane.position.y = 0.001;
299
+ shadowPlane.receiveShadow = true;
300
+ scene.add(shadowPlane);
301
+
302
+ // Grid with better styling
303
+ const gridHelper = new THREE.GridHelper(10, 20, 0x30363d, 0x21262d);
304
+ gridHelper.position.y = 0.002;
305
+ scene.add(gridHelper);
306
+
307
  // Constants
308
  const CENTER = new THREE.Vector3(0, 0.75, 0);
309
  const BASE_DISTANCE = 2.5;
310
  const AZIMUTH_RADIUS = 2.4;
311
  const ELEVATION_RADIUS = 1.8;
312
+
313
  // State
314
  let azimuthAngle = props.value?.azimuth || 0;
315
  let elevationAngle = props.value?.elevation || 0;
316
+
317
  // Mappings
318
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
319
  const elevationSteps = [-90, 0, 90];
320
  const azimuthNames = {
321
+ 0: 'Front', 45: 'Right Front', 90: 'Right',
322
+ 135: 'Right Rear', 180: 'Rear', 225: 'Left Rear',
323
+ 270: 'Left', 315: 'Left Front'
 
 
 
 
 
324
  };
325
  const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
326
+
327
  function snapToNearest(value, steps) {
328
+ return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
 
 
329
  }
330
+
331
+ // Create placeholder texture (camera icon style)
332
  function createPlaceholderTexture() {
333
  const canvas = document.createElement('canvas');
334
  canvas.width = 256;
335
  canvas.height = 256;
336
  const ctx = canvas.getContext('2d');
337
+
338
+ // Background gradient
339
+ const gradient = ctx.createLinearGradient(0, 0, 256, 256);
340
+ gradient.addColorStop(0, '#2d333b');
341
+ gradient.addColorStop(1, '#22272e');
342
+ ctx.fillStyle = gradient;
343
  ctx.fillRect(0, 0, 256, 256);
344
+
345
+ // Border
346
+ ctx.strokeStyle = '#444c56';
347
+ ctx.lineWidth = 4;
348
+ ctx.strokeRect(10, 10, 236, 236);
349
+
350
+ // Camera icon
351
+ ctx.fillStyle = '#58a6ff';
352
+ ctx.beginPath();
353
+ ctx.roundRect(78, 98, 100, 70, 8);
354
+ ctx.fill();
355
+
356
+ // Lens
357
+ ctx.fillStyle = '#2d333b';
358
  ctx.beginPath();
359
+ ctx.arc(128, 133, 25, 0, Math.PI * 2);
360
  ctx.fill();
361
+
362
+ ctx.fillStyle = '#58a6ff';
363
  ctx.beginPath();
364
+ ctx.arc(128, 133, 18, 0, Math.PI * 2);
 
365
  ctx.fill();
366
+
367
+ // Flash
368
+ ctx.fillStyle = '#58a6ff';
369
  ctx.beginPath();
370
+ ctx.roundRect(138, 88, 30, 15, 3);
371
+ ctx.fill();
372
+
373
+ // Text
374
+ ctx.fillStyle = '#8b949e';
375
+ ctx.font = '16px sans-serif';
376
+ ctx.textAlign = 'center';
377
+ ctx.fillText('Upload Image', 128, 200);
378
+
379
  return new THREE.CanvasTexture(canvas);
380
  }
381
+
382
  // Target image plane
383
  let currentTexture = createPlaceholderTexture();
384
+ const planeMaterial = new THREE.MeshStandardMaterial({
385
+ map: currentTexture,
386
+ side: THREE.DoubleSide,
387
+ roughness: 0.4,
388
+ metalness: 0.1
389
  });
390
+ let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
 
 
 
391
  targetPlane.position.copy(CENTER);
392
  targetPlane.receiveShadow = true;
393
+ targetPlane.castShadow = true;
394
  scene.add(targetPlane);
395
+
396
+ // Frame around the image
397
+ const frameGeometry = new THREE.BoxGeometry(1.3, 1.3, 0.05);
398
+ const frameMaterial = new THREE.MeshStandardMaterial({
399
+ color: 0x30363d,
400
+ roughness: 0.5,
401
+ metalness: 0.3
402
+ });
403
+ const frame = new THREE.Mesh(frameGeometry, frameMaterial);
404
+ frame.position.set(CENTER.x, CENTER.y, -0.03);
405
+ scene.add(frame);
406
+
407
+ // Function to update texture from image URL
408
  function updateTextureFromUrl(url) {
409
  if (!url) {
 
410
  planeMaterial.map = createPlaceholderTexture();
411
  planeMaterial.needsUpdate = true;
 
412
  scene.remove(targetPlane);
413
+ scene.remove(frame);
414
+ targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
 
 
415
  targetPlane.position.copy(CENTER);
416
  targetPlane.receiveShadow = true;
417
+ targetPlane.castShadow = true;
418
  scene.add(targetPlane);
419
+
420
+ frame.geometry = new THREE.BoxGeometry(1.3, 1.3, 0.05);
421
+ frame.position.set(CENTER.x, CENTER.y, -0.03);
422
+ scene.add(frame);
423
  return;
424
  }
425
+
426
  const loader = new THREE.TextureLoader();
427
  loader.crossOrigin = 'anonymous';
428
+ loader.load(url, (texture) => {
429
+ texture.minFilter = THREE.LinearFilter;
430
+ texture.magFilter = THREE.LinearFilter;
431
+ planeMaterial.map = texture;
432
+ planeMaterial.needsUpdate = true;
433
+
434
+ const img = texture.image;
435
+ if (img && img.width && img.height) {
436
+ const aspect = img.width / img.height;
437
+ const maxSize = 1.5;
438
+ let planeWidth, planeHeight;
439
+ if (aspect > 1) {
440
+ planeWidth = maxSize;
441
+ planeHeight = maxSize / aspect;
442
+ } else {
443
+ planeHeight = maxSize;
444
+ planeWidth = maxSize * aspect;
 
 
 
 
 
 
 
 
 
 
 
445
  }
446
+ scene.remove(targetPlane);
447
+ scene.remove(frame);
448
+
449
+ targetPlane = new THREE.Mesh(
450
+ new THREE.PlaneGeometry(planeWidth, planeHeight),
451
+ planeMaterial
452
+ );
453
+ targetPlane.position.copy(CENTER);
454
+ targetPlane.receiveShadow = true;
455
+ targetPlane.castShadow = true;
456
+ scene.add(targetPlane);
457
+
458
+ frame.geometry = new THREE.BoxGeometry(planeWidth + 0.1, planeHeight + 0.1, 0.05);
459
+ frame.position.set(CENTER.x, CENTER.y, -0.03);
460
+ scene.add(frame);
461
  }
462
+ }, undefined, (err) => {
463
+ console.error('Failed to load texture:', err);
464
+ });
465
  }
466
+
 
467
  if (props.imageUrl) {
468
  updateTextureFromUrl(props.imageUrl);
469
  }
470
+
471
+ // =============================================
472
+ // SOFTBOX LIGHT - Red box with white diffuser
473
+ // =============================================
474
  const lightGroup = new THREE.Group();
475
+
476
+ // Main softbox frame (red)
477
+ const softboxDepth = 0.4;
478
+ const softboxWidth = 0.7;
479
+ const softboxHeight = 0.7;
480
+
481
+ // Back panel (red)
482
+ const backPanel = new THREE.Mesh(
483
+ new THREE.BoxGeometry(softboxWidth, softboxHeight, 0.05),
484
+ new THREE.MeshStandardMaterial({
485
+ color: 0xcc2222,
486
+ roughness: 0.7,
487
+ metalness: 0.3
488
+ })
489
+ );
490
+ backPanel.position.z = softboxDepth / 2;
491
+ lightGroup.add(backPanel);
492
+
493
+ // Side panels (red, tapered)
494
+ const sideGeometry = new THREE.BoxGeometry(0.04, softboxHeight, softboxDepth);
495
+ const sideMaterial = new THREE.MeshStandardMaterial({
496
+ color: 0xaa1111,
497
+ roughness: 0.6,
498
+ metalness: 0.2
499
  });
500
+
501
+ const leftSide = new THREE.Mesh(sideGeometry, sideMaterial);
502
+ leftSide.position.set(-softboxWidth/2 + 0.02, 0, 0);
503
+ lightGroup.add(leftSide);
504
+
505
+ const rightSide = new THREE.Mesh(sideGeometry, sideMaterial);
506
+ rightSide.position.set(softboxWidth/2 - 0.02, 0, 0);
507
+ lightGroup.add(rightSide);
508
+
509
+ // Top and bottom panels (red)
510
+ const tbGeometry = new THREE.BoxGeometry(softboxWidth, 0.04, softboxDepth);
511
+
512
+ const topPanel = new THREE.Mesh(tbGeometry, sideMaterial);
513
+ topPanel.position.set(0, softboxHeight/2 - 0.02, 0);
514
+ lightGroup.add(topPanel);
515
+
516
+ const bottomPanel = new THREE.Mesh(tbGeometry, sideMaterial);
517
+ bottomPanel.position.set(0, -softboxHeight/2 + 0.02, 0);
518
+ lightGroup.add(bottomPanel);
519
+
520
+ // White diffuser front (emissive white)
521
+ const diffuserMaterial = new THREE.MeshStandardMaterial({
522
  color: 0xffffff,
523
  emissive: 0xffffff,
524
+ emissiveIntensity: 2.0,
525
+ roughness: 0.2,
526
+ metalness: 0,
527
+ transparent: true,
528
+ opacity: 0.95
 
 
 
 
 
 
 
 
 
 
529
  });
530
+
531
+ const diffuser = new THREE.Mesh(
532
+ new THREE.PlaneGeometry(softboxWidth - 0.08, softboxHeight - 0.08),
533
+ diffuserMaterial
 
 
 
 
 
 
 
 
534
  );
535
+ diffuser.position.z = -softboxDepth / 2 + 0.02;
536
+ lightGroup.add(diffuser);
537
+
538
+ // Inner glow effect
539
+ const innerGlow = new THREE.Mesh(
540
+ new THREE.PlaneGeometry(softboxWidth - 0.15, softboxHeight - 0.15),
541
+ new THREE.MeshBasicMaterial({
542
+ color: 0xffffff,
543
+ transparent: true,
544
+ opacity: 0.8
545
+ })
546
+ );
547
+ innerGlow.position.z = -softboxDepth / 2 + 0.01;
548
+ lightGroup.add(innerGlow);
549
+
550
+ // Mounting bracket
551
+ const bracket = new THREE.Mesh(
552
+ new THREE.CylinderGeometry(0.03, 0.03, 0.15, 8),
553
+ new THREE.MeshStandardMaterial({
554
+ color: 0x333333,
555
+ roughness: 0.5,
556
+ metalness: 0.8
557
+ })
558
+ );
559
+ bracket.rotation.x = Math.PI / 2;
560
+ bracket.position.z = softboxDepth / 2 + 0.075;
561
+ lightGroup.add(bracket);
562
+
563
+ // Ball joint
564
+ const ballJoint = new THREE.Mesh(
565
+ new THREE.SphereGeometry(0.05, 16, 16),
566
+ new THREE.MeshStandardMaterial({
567
+ color: 0x222222,
568
+ roughness: 0.4,
569
+ metalness: 0.9
570
+ })
571
+ );
572
+ ballJoint.position.z = softboxDepth / 2 + 0.15;
573
+ lightGroup.add(ballJoint);
574
+
575
+ // SpotLight for actual lighting
576
+ const spotLight = new THREE.SpotLight(0xffffff, 15, 12, Math.PI / 4, 0.5, 1);
577
+ spotLight.position.set(0, 0, -softboxDepth / 2);
578
  spotLight.castShadow = true;
579
  spotLight.shadow.mapSize.width = 1024;
580
  spotLight.shadow.mapSize.height = 1024;
581
  spotLight.shadow.camera.near = 0.5;
582
+ spotLight.shadow.camera.far = 15;
583
+ spotLight.shadow.bias = -0.002;
584
  lightGroup.add(spotLight);
585
+
586
  const lightTarget = new THREE.Object3D();
587
  lightTarget.position.copy(CENTER);
588
  scene.add(lightTarget);
589
  spotLight.target = lightTarget;
590
+
591
+ // Point light for softer fill
592
+ const pointLight = new THREE.PointLight(0xffffff, 3, 8);
593
+ pointLight.position.set(0, 0, -softboxDepth / 2 - 0.1);
594
+ lightGroup.add(pointLight);
595
+
596
  scene.add(lightGroup);
597
+
598
+ // =============================================
599
+ // YELLOW: Azimuth ring and handle
600
+ // =============================================
 
 
601
  const azimuthRing = new THREE.Mesh(
602
+ new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.035, 16, 64),
603
+ new THREE.MeshStandardMaterial({
604
+ color: 0xffd700,
605
+ emissive: 0xffd700,
606
+ emissiveIntensity: 0.4,
607
+ roughness: 0.3,
608
+ metalness: 0.6
609
  })
610
  );
611
  azimuthRing.rotation.x = Math.PI / 2;
612
  azimuthRing.position.y = 0.05;
613
  scene.add(azimuthRing);
614
+
615
+ // Azimuth direction markers
616
+ azimuthSteps.forEach(angle => {
617
+ const rad = THREE.MathUtils.degToRad(angle);
618
+ const marker = new THREE.Mesh(
619
+ new THREE.SphereGeometry(0.06, 8, 8),
620
+ new THREE.MeshStandardMaterial({
621
+ color: 0xffd700,
622
+ emissive: 0xffd700,
623
+ emissiveIntensity: 0.2
624
+ })
625
+ );
626
+ marker.position.set(
627
+ AZIMUTH_RADIUS * Math.sin(rad),
628
+ 0.05,
629
+ AZIMUTH_RADIUS * Math.cos(rad)
630
+ );
631
+ scene.add(marker);
632
+ });
633
+
634
  const azimuthHandle = new THREE.Mesh(
635
+ new THREE.SphereGeometry(0.16, 24, 24),
636
+ new THREE.MeshStandardMaterial({
637
+ color: 0xffd700,
638
+ emissive: 0xffd700,
639
+ emissiveIntensity: 0.6,
640
+ roughness: 0.2,
641
+ metalness: 0.7
642
  })
643
  );
644
  azimuthHandle.userData.type = 'azimuth';
645
  scene.add(azimuthHandle);
646
+
647
+ // =============================================
648
+ // BLUE: Elevation arc and handle
649
+ // =============================================
650
  const arcPoints = [];
651
+ for (let i = 0; i <= 48; i++) {
652
+ const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 48));
653
+ arcPoints.push(new THREE.Vector3(
654
+ -0.8,
655
+ ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
656
+ ELEVATION_RADIUS * Math.cos(angle)
657
+ ));
 
 
658
  }
659
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
660
  const elevationArc = new THREE.Mesh(
661
+ new THREE.TubeGeometry(arcCurve, 48, 0.035, 12, false),
662
+ new THREE.MeshStandardMaterial({
663
+ color: 0x4dabf7,
664
+ emissive: 0x4dabf7,
665
+ emissiveIntensity: 0.4,
666
+ roughness: 0.3,
667
+ metalness: 0.6
668
  })
669
  );
670
  scene.add(elevationArc);
671
+
672
+ // Elevation markers
673
+ elevationSteps.forEach(angle => {
674
+ const rad = THREE.MathUtils.degToRad(angle);
675
+ const marker = new THREE.Mesh(
676
+ new THREE.SphereGeometry(0.06, 8, 8),
677
+ new THREE.MeshStandardMaterial({
678
+ color: 0x4dabf7,
679
+ emissive: 0x4dabf7,
680
+ emissiveIntensity: 0.2
681
+ })
682
+ );
683
+ marker.position.set(
684
+ -0.8,
685
+ ELEVATION_RADIUS * Math.sin(rad) + CENTER.y,
686
+ ELEVATION_RADIUS * Math.cos(rad)
687
+ );
688
+ scene.add(marker);
689
+ });
690
+
691
  const elevationHandle = new THREE.Mesh(
692
+ new THREE.SphereGeometry(0.16, 24, 24),
693
+ new THREE.MeshStandardMaterial({
694
+ color: 0x4dabf7,
695
+ emissive: 0x4dabf7,
696
+ emissiveIntensity: 0.6,
697
+ roughness: 0.2,
698
+ metalness: 0.7
699
  })
700
  );
701
  elevationHandle.userData.type = 'elevation';
702
  scene.add(elevationHandle);
703
+
704
+ // =============================================
705
+ // REFRESH BUTTON (styled button, not symbol)
706
+ // =============================================
707
  const refreshBtn = document.createElement('button');
708
+ refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg><span style="margin-left: 6px;">Reset</span>';
709
+ refreshBtn.style.cssText = `
710
+ position: absolute;
711
+ top: 10px;
712
+ right: 10px;
713
+ background: linear-gradient(135deg, #238636 0%, #2ea043 100%);
714
+ color: white;
715
+ border: none;
716
+ padding: 8px 14px;
717
+ border-radius: 6px;
718
+ cursor: pointer;
719
+ z-index: 10;
720
+ font-size: 13px;
721
+ font-weight: 500;
722
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
723
+ display: flex;
724
+ align-items: center;
725
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
726
+ transition: all 0.2s ease;
727
+ `;
728
+ refreshBtn.onmouseenter = () => {
729
+ refreshBtn.style.background = 'linear-gradient(135deg, #2ea043 0%, #3fb950 100%)';
730
+ refreshBtn.style.transform = 'translateY(-1px)';
731
+ refreshBtn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
732
+ };
733
+ refreshBtn.onmouseleave = () => {
734
+ refreshBtn.style.background = 'linear-gradient(135deg, #238636 0%, #2ea043 100%)';
735
+ refreshBtn.style.transform = 'translateY(0)';
736
+ refreshBtn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
737
+ };
738
  wrapper.appendChild(refreshBtn);
739
+
740
  refreshBtn.addEventListener('click', () => {
741
+ // Animate reset
742
+ const startAz = azimuthAngle;
743
+ const startEl = elevationAngle;
744
+ const startTime = Date.now();
745
+
746
+ function animateReset() {
747
+ const t = Math.min((Date.now() - startTime) / 400, 1);
748
+ const ease = 1 - Math.pow(1 - t, 4);
749
+
750
+ let azDiff = 0 - startAz;
751
+ if (azDiff > 180) azDiff -= 360;
752
+ if (azDiff < -180) azDiff += 360;
753
+ azimuthAngle = startAz + azDiff * ease;
754
+ if (azimuthAngle < 0) azimuthAngle += 360;
755
+ if (azimuthAngle >= 360) azimuthAngle -= 360;
756
+
757
+ elevationAngle = startEl + (0 - startEl) * ease;
758
+
759
+ updatePositions();
760
+
761
+ if (t < 1) {
762
+ requestAnimationFrame(animateReset);
763
+ } else {
764
+ azimuthAngle = 0;
765
+ elevationAngle = 0;
766
+ updatePositions();
767
+ updatePropsAndTrigger();
768
+ }
769
+ }
770
+ animateReset();
771
  });
772
+
 
 
 
773
  function updatePositions() {
774
  const distance = BASE_DISTANCE;
775
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
776
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
777
+
778
  const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
779
  const lightY = distance * Math.sin(elRad) + CENTER.y;
780
  const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
781
+
782
  lightGroup.position.set(lightX, lightY, lightZ);
783
  lightGroup.lookAt(CENTER);
784
+
785
  azimuthHandle.position.set(
786
+ AZIMUTH_RADIUS * Math.sin(azRad),
787
+ 0.05,
788
  AZIMUTH_RADIUS * Math.cos(azRad)
789
  );
790
  elevationHandle.position.set(
791
+ -0.8,
792
+ ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
793
  ELEVATION_RADIUS * Math.cos(elRad)
794
  );
795
+
796
+ // Update prompt
797
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
798
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
799
+ let prompt = '💡 Light source from';
800
  if (elSnap !== 0) {
801
  prompt += ' ' + elevationNames[String(elSnap)];
802
  } else {
 
804
  }
805
  promptOverlay.textContent = prompt;
806
  }
807
+
808
  function updatePropsAndTrigger() {
809
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
810
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
811
+
812
  props.value = { azimuth: azSnap, elevation: elSnap };
813
  trigger('change', props.value);
814
  }
815
+
816
+ // Raycasting
 
 
817
  const raycaster = new THREE.Raycaster();
818
  const mouse = new THREE.Vector2();
819
  let isDragging = false;
820
  let dragTarget = null;
821
  let dragStartMouse = new THREE.Vector2();
822
  const intersection = new THREE.Vector3();
823
+
824
  const canvas = renderer.domElement;
825
+
826
  canvas.addEventListener('mousedown', (e) => {
827
  const rect = canvas.getBoundingClientRect();
828
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
829
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
830
+
831
  raycaster.setFromCamera(mouse, camera);
832
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
833
+
 
 
 
834
  if (intersects.length > 0) {
835
  isDragging = true;
836
  dragTarget = intersects[0].object;
837
+ dragTarget.material.emissiveIntensity = 1.2;
838
  dragTarget.scale.setScalar(1.3);
839
  dragStartMouse.copy(mouse);
840
  canvas.style.cursor = 'grabbing';
841
  }
842
  });
843
+
844
  canvas.addEventListener('mousemove', (e) => {
845
  const rect = canvas.getBoundingClientRect();
846
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
847
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
848
+
849
  if (isDragging && dragTarget) {
850
  raycaster.setFromCamera(mouse, camera);
851
+
852
  if (dragTarget.userData.type === 'azimuth') {
853
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
 
 
 
854
  if (raycaster.ray.intersectPlane(plane, intersection)) {
855
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
 
 
856
  if (azimuthAngle < 0) azimuthAngle += 360;
857
  }
858
  } else if (dragTarget.userData.type === 'elevation') {
859
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0.8);
 
 
 
860
  if (raycaster.ray.intersectPlane(plane, intersection)) {
861
  const relY = intersection.y - CENTER.y;
862
  const relZ = intersection.z;
863
  elevationAngle = THREE.MathUtils.clamp(
864
+ THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
865
+ -90,
 
 
866
  90
867
  );
868
  }
 
870
  updatePositions();
871
  } else {
872
  raycaster.setFromCamera(mouse, camera);
873
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
874
+ [azimuthHandle, elevationHandle].forEach(h => {
 
 
 
875
  h.material.emissiveIntensity = 0.6;
876
  h.scale.setScalar(1);
877
  });
878
  if (intersects.length > 0) {
879
  intersects[0].object.material.emissiveIntensity = 0.9;
880
+ intersects[0].object.scale.setScalar(1.15);
881
  canvas.style.cursor = 'grab';
882
  } else {
883
  canvas.style.cursor = 'default';
884
  }
885
  }
886
  });
887
+
888
  const onMouseUp = () => {
889
  if (dragTarget) {
890
  dragTarget.material.emissiveIntensity = 0.6;
891
  dragTarget.scale.setScalar(1);
892
+
893
+ // Snap and animate
894
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
895
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
896
+
897
+ const startAz = azimuthAngle, startEl = elevationAngle;
 
898
  const startTime = Date.now();
899
+
900
  function animateSnap() {
901
  const t = Math.min((Date.now() - startTime) / 200, 1);
902
  const ease = 1 - Math.pow(1 - t, 3);
903
+
904
  let azDiff = targetAz - startAz;
905
  if (azDiff > 180) azDiff -= 360;
906
  if (azDiff < -180) azDiff += 360;
907
  azimuthAngle = startAz + azDiff * ease;
908
  if (azimuthAngle < 0) azimuthAngle += 360;
909
  if (azimuthAngle >= 360) azimuthAngle -= 360;
910
+
911
  elevationAngle = startEl + (targetEl - startEl) * ease;
912
+
913
  updatePositions();
914
  if (t < 1) requestAnimationFrame(animateSnap);
915
  else updatePropsAndTrigger();
 
920
  dragTarget = null;
921
  canvas.style.cursor = 'default';
922
  };
923
+
924
  canvas.addEventListener('mouseup', onMouseUp);
925
  canvas.addEventListener('mouseleave', onMouseUp);
926
+
927
+ // Touch support for mobile
928
+ canvas.addEventListener('touchstart', (e) => {
929
+ e.preventDefault();
930
+ const touch = e.touches[0];
931
+ const rect = canvas.getBoundingClientRect();
932
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
933
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
934
+
935
+ raycaster.setFromCamera(mouse, camera);
936
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
937
+
938
+ if (intersects.length > 0) {
939
+ isDragging = true;
940
+ dragTarget = intersects[0].object;
941
+ dragTarget.material.emissiveIntensity = 1.2;
942
+ dragTarget.scale.setScalar(1.3);
943
+ dragStartMouse.copy(mouse);
944
+ }
945
+ }, { passive: false });
946
+
947
+ canvas.addEventListener('touchmove', (e) => {
948
+ e.preventDefault();
949
+ const touch = e.touches[0];
950
+ const rect = canvas.getBoundingClientRect();
951
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
952
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
953
+
954
+ if (isDragging && dragTarget) {
955
  raycaster.setFromCamera(mouse, camera);
956
+
957
+ if (dragTarget.userData.type === 'azimuth') {
958
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
959
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
960
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
961
+ if (azimuthAngle < 0) azimuthAngle += 360;
962
+ }
963
+ } else if (dragTarget.userData.type === 'elevation') {
964
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0.8);
965
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
966
+ const relY = intersection.y - CENTER.y;
967
+ const relZ = intersection.z;
968
+ elevationAngle = THREE.MathUtils.clamp(
969
+ THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
970
+ -90,
971
+ 90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  );
 
 
 
 
 
 
 
 
 
 
 
973
  }
 
974
  }
975
+ updatePositions();
976
+ }
977
+ }, { passive: false });
978
+
979
+ canvas.addEventListener('touchend', (e) => {
980
+ e.preventDefault();
981
+ onMouseUp();
982
+ }, { passive: false });
983
+
984
+ canvas.addEventListener('touchcancel', (e) => {
985
+ e.preventDefault();
986
+ onMouseUp();
987
+ }, { passive: false });
988
+
 
 
 
 
 
 
 
 
989
  // Initial update
990
  updatePositions();
991
+
992
  // Render loop
993
  function render() {
994
  requestAnimationFrame(render);
995
  renderer.render(scene, camera);
996
  }
997
  render();
998
+
999
+ // Handle resize
1000
  new ResizeObserver(() => {
1001
  camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
1002
  camera.updateProjectionMatrix();
1003
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
1004
  }).observe(wrapper);
1005
+
1006
+ // Store update functions for external calls
1007
  wrapper._updateFromProps = (newVal) => {
1008
  if (newVal && typeof newVal === 'object') {
1009
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
 
1011
  updatePositions();
1012
  }
1013
  };
1014
+
1015
  wrapper._updateTexture = updateTextureFromUrl;
1016
+
1017
+ // Watch for prop changes
1018
  let lastImageUrl = props.imageUrl;
1019
  let lastValue = JSON.stringify(props.value);
1020
  setInterval(() => {
 
1033
  }
1034
  }, 100);
1035
  };
1036
+
1037
  initScene();
1038
  })();
1039
  """
1040
+
1041
  super().__init__(
1042
  value=value,
1043
  html_template=html_template,
1044
  js_on_load=js_on_load,
1045
  imageUrl=imageUrl,
1046
+ **kwargs
1047
  )
1048
 
1049
+ css = '''
 
 
 
 
1050
  #col-container { max-width: 1200px; margin: 0 auto; }
1051
  .dark .progress-text { color: white !important; }
1052
  #lighting-3d-control { min-height: 450px; }
1053
  .slider-row { display: flex; gap: 10px; align-items: center; }
1054
  #main-title h1 {font-size: 2.4em !important;}
1055
+ '''
1056
 
1057
  with gr.Blocks(css=css) as demo:
1058
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
1059
+ gr.Markdown("Control lighting directions using the **3D viewport** or **sliders**. Use [Multi-Angle-Lighting](https://huggingface.co/dx8152/Qwen-Edit-2509-Multi-Angle-Lighting) LoRA for precise lighting control, paired with [Rapid-AIO-V19](https://huggingface.co/prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19).")
1060
+
 
 
 
 
1061
  with gr.Row():
1062
  with gr.Column(scale=1):
1063
  image = gr.Image(label="Input Image", type="pil", height=300)
1064
+
1065
  gr.Markdown("### 3D Lighting Control")
1066
+ gr.Markdown("*Drag the colored handles: 🟡 Azimuth (Direction), 🔵 Elevation (Height)*")
1067
+
 
 
1068
  lighting_3d = LightingControl3D(
1069
  value={"azimuth": 0, "elevation": 0},
1070
+ elem_id="lighting-3d-control"
1071
  )
 
1072
  run_btn = gr.Button("Generate Image", variant="primary", size="lg")
1073
+
1074
  gr.Markdown("### Slider Controls")
1075
+
1076
  azimuth_slider = gr.Slider(
1077
  label="Azimuth (Horizontal Rotation)",
1078
  minimum=0,
1079
  maximum=315,
1080
  step=45,
1081
  value=0,
1082
+ info="0°=front, 90°=right, 180°=rear, 270°=left"
1083
  )
1084
+
1085
  elevation_slider = gr.Slider(
1086
  label="Elevation (Vertical Angle)",
1087
  minimum=-90,
1088
  maximum=90,
1089
  step=90,
1090
  value=0,
1091
+ info="-90°=from below, 0°=horizontal, 90°=from above"
1092
  )
1093
 
1094
  with gr.Row():
 
1098
  interactive=True,
1099
  lines=1,
1100
  )
1101
+
1102
  with gr.Column(scale=1):
1103
  result = gr.Image(label="Output Image", height=500)
1104
+
1105
  with gr.Accordion("Advanced Settings", open=False):
1106
+ seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
 
 
1107
  randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
1108
+ guidance_scale = gr.Slider(label="Guidance Scale", minimum=1.0, maximum=10.0, step=0.1, value=1.0)
1109
+ num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=20, step=1, value=4)
1110
+ height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
1111
+ width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
1112
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1113
  def update_prompt_from_sliders(azimuth, elevation):
1114
+ """Update prompt preview when sliders change."""
1115
+ prompt = build_lighting_prompt(azimuth, elevation)
1116
+ return prompt
1117
+
1118
  def sync_3d_to_sliders(lighting_value):
1119
+ """Sync 3D control changes to sliders."""
1120
  if lighting_value and isinstance(lighting_value, dict):
1121
+ az = lighting_value.get('azimuth', 0)
1122
+ el = lighting_value.get('elevation', 0)
1123
  prompt = build_lighting_prompt(az, el)
1124
  return az, el, prompt
1125
  return gr.update(), gr.update(), gr.update()
1126
+
1127
  def sync_sliders_to_3d(azimuth, elevation):
1128
+ """Sync slider changes to 3D control."""
1129
  return {"azimuth": azimuth, "elevation": elevation}
1130
+
1131
  def update_3d_image(image):
1132
+ """Update the 3D component with the uploaded image."""
1133
  if image is None:
1134
  return gr.update(imageUrl=None)
1135
+
1136
  import base64
1137
  from io import BytesIO
 
1138
  buffered = BytesIO()
1139
  image.save(buffered, format="PNG")
1140
  img_str = base64.b64encode(buffered.getvalue()).decode()
1141
  data_url = f"data:image/png;base64,{img_str}"
1142
  return gr.update(imageUrl=data_url)
1143
+
 
1144
  for slider in [azimuth_slider, elevation_slider]:
1145
  slider.change(
1146
  fn=update_prompt_from_sliders,
1147
  inputs=[azimuth_slider, elevation_slider],
1148
+ outputs=[prompt_preview]
1149
  )
1150
+
1151
  lighting_3d.change(
1152
  fn=sync_3d_to_sliders,
1153
  inputs=[lighting_3d],
1154
+ outputs=[azimuth_slider, elevation_slider, prompt_preview]
1155
  )
1156
+
1157
  for slider in [azimuth_slider, elevation_slider]:
1158
  slider.release(
1159
  fn=sync_sliders_to_3d,
1160
  inputs=[azimuth_slider, elevation_slider],
1161
+ outputs=[lighting_3d]
1162
  )
1163
+
1164
  run_btn.click(
1165
  fn=infer_lighting_edit,
1166
+ inputs=[image, azimuth_slider, elevation_slider, seed, randomize_seed, guidance_scale, num_inference_steps, height, width],
1167
+ outputs=[result, seed, prompt_preview]
 
 
 
 
 
 
 
 
 
 
1168
  )
1169
+
1170
  image.upload(
1171
  fn=update_dimensions_on_upload,
1172
  inputs=[image],
1173
+ outputs=[width, height]
1174
  ).then(
1175
  fn=update_3d_image,
1176
  inputs=[image],
1177
+ outputs=[lighting_3d]
1178
  )
1179
+
1180
  image.clear(
1181
  fn=lambda: gr.update(imageUrl=None),
1182
+ outputs=[lighting_3d]
1183
  )
1184
+
1185
  if __name__ == "__main__":
1186
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
1187
+ css = '.fillable{max-width: 1200px !important}'
1188
+ demo.launch(head=head, css=css, theme=orange_red_theme, mcp_server=True, ssr_mode=False, show_error=True)