ArtelTaleb commited on
Commit
ed2869f
·
verified ·
1 Parent(s): 78470dc

feat: 3D viewer — image source affichée sur le panneau sujet + lerp caméra

Browse files
Files changed (1) hide show
  1. app.py +192 -175
app.py CHANGED
@@ -7,57 +7,59 @@ from PIL import Image
7
 
8
  # ── Constantes poses ──────────────────────────────────────────────────────────
9
 
10
- AZIMUTHS = [0, 45, 90, 135, 180, 225, 270, 315]
11
  ELEVATIONS = [-30, 0, 30, 60]
12
- DISTANCES = [0.6, 1.0, 1.8]
13
 
14
  AZIMUTH_NAMES = {
15
- 0: "front view", 45: "front-right quarter view", 90: "right side view",
16
- 135: "back-right quarter view", 180: "back view", 225: "back-left quarter view",
17
- 270: "left side view", 315: "front-left quarter view"
18
  }
19
  ELEVATION_NAMES = {
20
- -30: "low-angle shot", 0: "eye-level shot", 30: "elevated shot", 60: "high-angle shot"
21
  }
22
- DISTANCE_NAMES = {0.6: "close-up", 1.0: "medium shot", 1.8: "wide shot"}
23
 
24
 
25
- def snap_to_nearest(value, options):
26
  return min(options, key=lambda x: abs(x - value))
27
 
28
 
29
- def build_camera_prompt(azimuth: float, elevation: float, distance: float) -> str:
30
- az = snap_to_nearest(azimuth, AZIMUTHS)
31
- el = snap_to_nearest(elevation, ELEVATIONS)
32
- di = snap_to_nearest(distance, DISTANCES)
33
  return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
34
 
35
 
36
- def update_prompt_preview(azimuth, elevation, distance):
37
- return build_camera_prompt(azimuth, elevation, distance)
38
-
39
-
40
- def pil_to_data_uri(img: Image.Image) -> str:
41
  buf = io.BytesIO()
42
  img.save(buf, format="PNG")
43
  b64 = base64.b64encode(buf.getvalue()).decode()
44
  return f"data:image/png;base64,{b64}"
45
 
 
 
 
 
46
  # ── Inférence fal.ai ──────────────────────────────────────────────────────────
47
 
48
  def infer(image, azimuth, elevation, distance, seed, randomize_seed):
49
  if image is None:
50
- raise gr.Error("Veuillez uploader une image source / Please upload a source image")
51
  if randomize_seed:
52
  seed = random.randint(0, 2**31 - 1)
53
 
54
- prompt = build_camera_prompt(azimuth, elevation, distance)
55
- image_url = pil_to_data_uri(image)
56
 
 
57
  result = fal_client.run(
58
  "fal-ai/qwen-image-edit",
59
  arguments={
60
- "image_url": image_url,
61
  "prompt": prompt,
62
  "seed": seed,
63
  "image_size": {"width": 1024, "height": 1024},
@@ -66,23 +68,22 @@ def infer(image, azimuth, elevation, distance, seed, randomize_seed):
66
  "loras": [{"path": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA", "scale": 1.0}],
67
  },
68
  )
69
-
70
- import urllib.request
71
  with urllib.request.urlopen(result["images"][0]["url"]) as resp:
72
  out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
73
-
74
  return out_img, seed, prompt
75
 
76
- # ── Viewer 3D Three.js ────────────────────────────────────────────────────────
77
 
78
- VIEWER_HTML = """
79
- <div style="position:relative;width:100%;border-radius:10px;overflow:hidden;background:#080810;">
80
  <canvas id="as-canvas" style="width:100%;display:block;"></canvas>
 
 
81
  <div id="as-prompt" style="
82
- position:absolute;bottom:12px;left:50%;transform:translateX(-50%);
83
- background:rgba(8,8,16,0.88);border:1px solid #00ff88;border-radius:5px;
84
- padding:7px 14px;font-family:monospace;font-size:12px;color:#00ff88;
85
- white-space:nowrap;pointer-events:none;letter-spacing:0.03em;">
86
  &lt;sks&gt; front view, eye-level shot, medium shot
87
  </div>
88
  </div>
@@ -90,123 +91,128 @@ VIEWER_HTML = """
90
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
91
  <script>
92
  (function () {
93
- const canvas = document.getElementById('as-canvas');
94
- const promptEl = document.getElementById('as-prompt');
95
-
96
- const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
97
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
98
- renderer.setClearColor(0x080810, 1);
 
 
99
 
100
- const scene = new THREE.Scene();
101
- const viewCam = new THREE.PerspectiveCamera(42, 1, 0.01, 50);
102
- viewCam.position.set(2.6, 1.9, 2.6);
103
- viewCam.lookAt(0, 0.15, 0);
104
 
105
  function resize() {
106
- const w = canvas.clientWidth || 480;
107
- const h = Math.round(w * 0.72);
108
  renderer.setSize(w, h, false);
109
- viewCam.aspect = w / h;
110
- viewCam.updateProjectionMatrix();
111
  }
112
  resize();
113
  new ResizeObserver(resize).observe(canvas.parentElement);
114
 
115
- // ── Floor grid ──
116
- const grid = new THREE.GridHelper(5, 20, 0x151528, 0x151528);
117
  scene.add(grid);
118
 
119
- // ── Azimuth ring ──
120
- const ringGeo = new THREE.TorusGeometry(1.0, 0.014, 8, 96);
121
- const ringMat = new THREE.MeshBasicMaterial({ color: 0x00ff88 });
122
- const ring = new THREE.Mesh(ringGeo, ringMat);
123
- ring.rotation.x = Math.PI / 2;
124
- scene.add(ring);
125
-
126
- // 8 orbit snap dots
127
- const dotGeo = new THREE.SphereGeometry(0.028, 8, 8);
128
- [0, 45, 90, 135, 180, 225, 270, 315].forEach(function(az) {
129
- const dot = new THREE.Mesh(dotGeo, new THREE.MeshBasicMaterial({ color: 0x00ff88 }));
130
- const r = az * Math.PI / 180;
131
- dot.position.set(Math.sin(r), 0, Math.cos(r));
132
- scene.add(dot);
 
 
 
 
 
133
  });
134
 
135
- // ── Elevation arc (left side) ──
136
  var arcPts = [];
137
- for (var e = -30; e <= 60; e += 3) {
138
  var er = e * Math.PI / 180;
139
- arcPts.push(new THREE.Vector3(-1.0 * Math.cos(er), Math.sin(er), 0));
140
  }
141
- var arcGeo = new THREE.BufferGeometry().setFromPoints(arcPts);
142
- scene.add(new THREE.Line(arcGeo, new THREE.LineBasicMaterial({ color: 0xff6ec7 })));
143
-
144
- // Elevation arc dot markers
145
- [-30, 0, 30, 60].forEach(function(e) {
 
146
  var er = e * Math.PI / 180;
147
- var m = new THREE.Mesh(
148
- new THREE.SphereGeometry(0.025, 8, 8),
149
  new THREE.MeshBasicMaterial({ color: 0xff6ec7 })
150
  );
151
- m.position.set(-1.0 * Math.cos(er), Math.sin(er), 0);
152
- scene.add(m);
153
  });
154
 
155
- // ── Subject at center ──
156
- var subGeo = new THREE.BoxGeometry(0.17, 0.22, 0.025);
157
- var subMesh = new THREE.Mesh(subGeo, new THREE.MeshBasicMaterial({ color: 0x223355 }));
158
- subMesh.position.y = 0.11;
 
 
159
  scene.add(subMesh);
160
- var subEdges = new THREE.LineSegments(
161
- new THREE.EdgesGeometry(subGeo),
162
- new THREE.LineBasicMaterial({ color: 0x6688bb })
163
- );
164
- subEdges.position.copy(subMesh.position);
165
- scene.add(subEdges);
166
-
167
- // ── Camera marker (sphere + barrel) ──
168
- var camSphGeo = new THREE.SphereGeometry(0.065, 16, 16);
169
  var camSphMat = new THREE.MeshBasicMaterial({ color: 0xffcc00 });
170
- var camSph = new THREE.Mesh(camSphGeo, camSphMat);
171
  scene.add(camSph);
172
 
173
- var barrelGeo = new THREE.CylinderGeometry(0.022, 0.032, 0.11, 8);
174
- var barrelMat = new THREE.MeshBasicMaterial({ color: 0x2a3a4a });
175
- var barrel = new THREE.Mesh(barrelGeo, barrelMat);
176
  scene.add(barrel);
177
 
178
- // ── Beam line ──
179
- var beamPts = [new THREE.Vector3(0,0,0), new THREE.Vector3(0,0,0)];
180
- var beamGeo = new THREE.BufferGeometry().setFromPoints(beamPts);
181
- var beamMat = new THREE.LineBasicMaterial({ color: 0xffcc00, transparent: true, opacity: 0.4 });
182
- var beam = new THREE.Line(beamGeo, beamMat);
183
- scene.add(beam);
184
-
185
- // ── State ──
186
- var curAz = 0, curEl = 0, curDi = 1.0;
187
- var tgtAz = 0, tgtEl = 0, tgtDi = 1.0;
188
-
189
- var AZNAMES = {
190
- 0:'front view', 45:'front-right quarter view', 90:'right side view',
191
- 135:'back-right quarter view', 180:'back view', 225:'back-left quarter view',
192
- 270:'left side view', 315:'front-left quarter view'
193
- };
194
- var ELNAMES = { '-30':'low-angle shot', '0':'eye-level shot', '30':'elevated shot', '60':'high-angle shot' };
195
- var DINAMES = { '0.6':'close-up', '1':'medium shot', '1.8':'wide shot' };
196
-
197
- function snap(v, opts) {
198
- return opts.reduce(function(a,b){ return Math.abs(b-v)<Math.abs(a-v)?b:a; });
199
- }
200
 
201
  function buildPrompt(az, el, di) {
202
- var saz = snap(az, [0,45,90,135,180,225,270,315]);
203
- var sel = snap(el, [-30,0,30,60]);
204
- var sdi = snap(di, [0.6,1.0,1.8]);
205
- var diKey = sdi === 1.0 ? '1' : sdi.toString();
206
- return '<sks> ' + AZNAMES[saz] + ', ' + ELNAMES[sel.toString()] + ', ' + DINAMES[diKey];
207
  }
208
 
209
- var EL_COLORS = { '-30':0x4fc3f7, '0':0x69f0ae, '30':0xffb74d, '60':0xef5350 };
210
 
211
  function getSlider(id, def) {
212
  var el = document.getElementById(id);
@@ -215,7 +221,16 @@ VIEWER_HTML = """
215
  return inp ? parseFloat(inp.value) : def;
216
  }
217
 
218
- function updateCam(az, el, di) {
 
 
 
 
 
 
 
 
 
219
  var azR = az * Math.PI / 180;
220
  var elR = el * Math.PI / 180;
221
  var x = di * Math.sin(azR) * Math.cos(elR);
@@ -224,56 +239,52 @@ VIEWER_HTML = """
224
 
225
  camSph.position.set(x, y, z);
226
 
227
- // Barrel orientation (points toward subject)
228
  barrel.position.set(x, y, z);
229
  var dir = new THREE.Vector3(-x, -y, -z).normalize();
230
- var up = new THREE.Vector3(0, 1, 0);
231
- barrel.quaternion.setFromUnitVectors(up, dir);
232
- barrel.translateY(0.045);
233
-
234
- // Beam
235
- var pos = beamGeo.attributes.position;
236
- pos.setXYZ(0, x, y, z);
237
- pos.setXYZ(1, 0, 0.11, 0);
238
- pos.needsUpdate = true;
239
-
240
- // Color by elevation
241
- var sel = snap(el, [-30, 0, 30, 60]);
242
- var col = EL_COLORS[sel.toString()] || 0x69f0ae;
243
  camSphMat.color.setHex(col);
244
  beamMat.color.setHex(col);
245
 
246
  promptEl.textContent = buildPrompt(az, el, di);
247
  }
248
 
249
- // ── Viewer orbit (slow auto-rotate for dynamism) ──
250
- var viewT = 0;
251
-
252
  function animate() {
253
  requestAnimationFrame(animate);
254
 
255
- // Read Gradio sliders
256
- tgtAz = getSlider('az_slider', 0);
257
- tgtEl = getSlider('el_slider', 0);
258
- tgtDi = getSlider('di_slider', 1.0);
259
-
260
- // Smooth interpolation
261
- var k = 0.10;
262
- curAz += (tgtAz - curAz) * k;
263
- curEl += (tgtEl - curEl) * k;
264
- curDi += (tgtDi - curDi) * k;
265
-
266
- updateCam(curAz, curEl, curDi);
267
-
268
- // Slowly orbit the viewer camera around the scene
269
- viewT += 0.004;
270
- var vr = 3.4;
271
- viewCam.position.x = Math.sin(viewT) * vr * 0.65 + 0.6;
272
- viewCam.position.z = Math.cos(viewT) * vr * 0.65 + 0.6;
273
- viewCam.position.y = 1.7 + Math.sin(viewT * 0.3) * 0.3;
274
- viewCam.lookAt(0, 0.12, 0);
275
-
276
- renderer.render(scene, viewCam);
 
 
277
  }
278
  animate();
279
  })();
@@ -283,57 +294,63 @@ VIEWER_HTML = """
283
  # ── UI Gradio ─────────────────────────────────────────────────────────────────
284
 
285
  with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
286
- gr.Markdown(
287
- """
288
  # 🎥 Angle Studio
289
  **Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
290
  *Change the camera angle of any image — 96 precise poses*
291
- """
292
- )
293
 
294
  with gr.Row():
 
295
  with gr.Column(scale=1):
296
  input_image = gr.Image(label="Image source / Source image", type="pil")
297
 
298
- gr.Markdown("### 📷 Contrôle caméra / Camera Control")
 
299
 
 
300
  azimuth_slider = gr.Slider(
301
  minimum=0, maximum=315, step=45, value=0, elem_id="az_slider",
302
- label="Azimut — rotation horizontale (0°=front · 90°=right · 180°=back)"
303
  )
304
  elevation_slider = gr.Slider(
305
  minimum=-30, maximum=60, step=30, value=0, elem_id="el_slider",
306
- label="Élévation — angle vertical (-30°=bas · 0°=eye-level · 60°=haut)"
307
  )
308
  distance_slider = gr.Slider(
309
  minimum=0.6, maximum=1.8, step=0.6, value=1.0, elem_id="di_slider",
310
- label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
311
  )
312
 
313
  prompt_preview = gr.Textbox(
314
- label="Prompt / Generated prompt",
315
- value="<sks> front view, eye-level shot, medium shot",
316
- interactive=False,
317
  )
318
 
319
  with gr.Row():
320
  seed_input = gr.Number(label="Seed", value=0, precision=0)
321
- randomize = gr.Checkbox(label="Seed aléatoire / Random seed", value=True)
322
 
323
  generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
324
 
 
325
  with gr.Column(scale=1):
326
  gr.HTML(VIEWER_HTML)
327
  output_image = gr.Image(label="Résultat / Result", type="pil")
328
- output_seed = gr.Number(label="Seed utilisé / Used seed", interactive=False)
329
 
330
  gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
331
- gallery = gr.Gallery(label="Générations / Generations", columns=4, height=280)
332
  session_images = gr.State([])
333
 
334
- slider_inputs = [azimuth_slider, elevation_slider, distance_slider]
335
- for slider in slider_inputs:
336
- slider.change(fn=update_prompt_preview, inputs=slider_inputs, outputs=prompt_preview)
 
 
 
 
 
337
 
338
  def run_and_append(image, az, el, di, seed, rand, history):
339
  result, used_seed, prompt = infer(image, az, el, di, seed, rand)
@@ -342,7 +359,7 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
342
 
343
  generate_btn.click(
344
  fn=run_and_append,
345
- inputs=[input_image, *slider_inputs, seed_input, randomize, session_images],
346
  outputs=[output_image, output_seed, session_images, gallery],
347
  )
348
 
 
7
 
8
  # ── Constantes poses ──────────────────────────────────────────────────────────
9
 
10
+ AZIMUTHS = [0, 45, 90, 135, 180, 225, 270, 315]
11
  ELEVATIONS = [-30, 0, 30, 60]
12
+ DISTANCES = [0.6, 1.0, 1.8]
13
 
14
  AZIMUTH_NAMES = {
15
+ 0:"front view", 45:"front-right quarter view", 90:"right side view",
16
+ 135:"back-right quarter view", 180:"back view", 225:"back-left quarter view",
17
+ 270:"left side view", 315:"front-left quarter view"
18
  }
19
  ELEVATION_NAMES = {
20
+ -30:"low-angle shot", 0:"eye-level shot", 30:"elevated shot", 60:"high-angle shot"
21
  }
22
+ DISTANCE_NAMES = {0.6:"close-up", 1.0:"medium shot", 1.8:"wide shot"}
23
 
24
 
25
+ def snap(value, options):
26
  return min(options, key=lambda x: abs(x - value))
27
 
28
 
29
+ def build_prompt(azimuth, elevation, distance):
30
+ az = snap(azimuth, AZIMUTHS)
31
+ el = snap(elevation, ELEVATIONS)
32
+ di = snap(distance, DISTANCES)
33
  return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
34
 
35
 
36
+ def image_to_uri(img):
37
+ if img is None:
38
+ return ""
 
 
39
  buf = io.BytesIO()
40
  img.save(buf, format="PNG")
41
  b64 = base64.b64encode(buf.getvalue()).decode()
42
  return f"data:image/png;base64,{b64}"
43
 
44
+
45
+ def update_prompt(az, el, di):
46
+ return build_prompt(az, el, di)
47
+
48
  # ── Inférence fal.ai ──────────────────────────────────────────────────────────
49
 
50
  def infer(image, azimuth, elevation, distance, seed, randomize_seed):
51
  if image is None:
52
+ raise gr.Error("Upload a source image first")
53
  if randomize_seed:
54
  seed = random.randint(0, 2**31 - 1)
55
 
56
+ prompt = build_prompt(azimuth, elevation, distance)
 
57
 
58
+ import urllib.request
59
  result = fal_client.run(
60
  "fal-ai/qwen-image-edit",
61
  arguments={
62
+ "image_url": image_to_uri(image),
63
  "prompt": prompt,
64
  "seed": seed,
65
  "image_size": {"width": 1024, "height": 1024},
 
68
  "loras": [{"path": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA", "scale": 1.0}],
69
  },
70
  )
 
 
71
  with urllib.request.urlopen(result["images"][0]["url"]) as resp:
72
  out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
 
73
  return out_img, seed, prompt
74
 
75
+ # ── Viewer 3D ─────────────────────────────────────────────────────────────────
76
 
77
+ VIEWER_HTML = r"""
78
+ <div style="position:relative;width:100%;border-radius:10px;overflow:hidden;background:#07070f;user-select:none;">
79
  <canvas id="as-canvas" style="width:100%;display:block;"></canvas>
80
+
81
+ <!-- prompt overlay -->
82
  <div id="as-prompt" style="
83
+ position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
84
+ background:rgba(7,7,15,0.88);border:1px solid #00ff88;border-radius:5px;
85
+ padding:6px 14px;font-family:'Courier New',monospace;font-size:11px;
86
+ color:#00ff88;white-space:nowrap;pointer-events:none;">
87
  &lt;sks&gt; front view, eye-level shot, medium shot
88
  </div>
89
  </div>
 
91
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
92
  <script>
93
  (function () {
94
+ /* ── renderer ── */
95
+ var canvas = document.getElementById('as-canvas');
96
+ var promptEl = document.getElementById('as-prompt');
97
+ var renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
98
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
99
+ renderer.setClearColor(0x07070f, 1);
100
+
101
+ var scene = new THREE.Scene();
102
 
103
+ /* ── viewer camera (fixed 3/4 angle) ── */
104
+ var vcam = new THREE.PerspectiveCamera(38, 1, 0.01, 50);
105
+ vcam.position.set(2.8, 2.0, 2.8);
106
+ vcam.lookAt(0, 0.1, 0);
107
 
108
  function resize() {
109
+ var w = canvas.parentElement.clientWidth || 500;
110
+ var h = Math.round(w * 0.72);
111
  renderer.setSize(w, h, false);
112
+ vcam.aspect = w / h;
113
+ vcam.updateProjectionMatrix();
114
  }
115
  resize();
116
  new ResizeObserver(resize).observe(canvas.parentElement);
117
 
118
+ /* ── grid floor ── */
119
+ var grid = new THREE.GridHelper(6, 24, 0x111128, 0x111128);
120
  scene.add(grid);
121
 
122
+ /* ── azimuth ring (green torus on XZ plane) ── */
123
+ var torusMat = new THREE.MeshBasicMaterial({ color: 0x00ff88 });
124
+ [0.60, 1.00, 1.80].forEach(function(di) {
125
+ var t = new THREE.Mesh(
126
+ new THREE.TorusGeometry(di, 0.013, 8, 80),
127
+ new THREE.MeshBasicMaterial({ color: di === 1.00 ? 0x00ff88 : 0x00884a, transparent: true, opacity: di === 1.00 ? 1 : 0.4 })
128
+ );
129
+ t.rotation.x = Math.PI / 2;
130
+ scene.add(t);
131
+ });
132
+
133
+ /* snap dots on main ring */
134
+ var dotGeo = new THREE.SphereGeometry(0.025, 8, 8);
135
+ var dotMat = new THREE.MeshBasicMaterial({ color: 0x00ff88 });
136
+ [0,45,90,135,180,225,270,315].forEach(function(a) {
137
+ var ar = a * Math.PI / 180;
138
+ var d = new THREE.Mesh(dotGeo, dotMat);
139
+ d.position.set(Math.sin(ar), 0, Math.cos(ar));
140
+ scene.add(d);
141
  });
142
 
143
+ /* ── elevation arc (pink, left side XY plane) ── */
144
  var arcPts = [];
145
+ for (var e = -35; e <= 65; e += 3) {
146
  var er = e * Math.PI / 180;
147
+ arcPts.push(new THREE.Vector3(-Math.cos(er), Math.sin(er), 0));
148
  }
149
+ scene.add(new THREE.Line(
150
+ new THREE.BufferGeometry().setFromPoints(arcPts),
151
+ new THREE.LineBasicMaterial({ color: 0xff6ec7, linewidth: 2 })
152
+ ));
153
+ /* snap dots on arc */
154
+ [-30,0,30,60].forEach(function(e) {
155
  var er = e * Math.PI / 180;
156
+ var d = new THREE.Mesh(
157
+ new THREE.SphereGeometry(0.022, 8, 8),
158
  new THREE.MeshBasicMaterial({ color: 0xff6ec7 })
159
  );
160
+ d.position.set(-Math.cos(er), Math.sin(er), 0);
161
+ scene.add(d);
162
  });
163
 
164
+ /* ── subject panel (shows uploaded image) ── */
165
+ var subW = 0.28, subH = 0.36;
166
+ var subGeo = new THREE.PlaneGeometry(subW, subH);
167
+ var subMat = new THREE.MeshBasicMaterial({ color: 0x223355, side: THREE.DoubleSide });
168
+ var subMesh = new THREE.Mesh(subGeo, subMat);
169
+ subMesh.position.y = subH / 2 + 0.01;
170
  scene.add(subMesh);
171
+ /* border */
172
+ var borderGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(subW, subH, 0.01));
173
+ var borderLine = new THREE.LineSegments(borderGeo, new THREE.LineBasicMaterial({ color: 0x4466aa }));
174
+ borderLine.position.copy(subMesh.position);
175
+ scene.add(borderLine);
176
+
177
+ /* ── camera marker: sphere + barrel ── */
 
 
178
  var camSphMat = new THREE.MeshBasicMaterial({ color: 0xffcc00 });
179
+ var camSph = new THREE.Mesh(new THREE.SphereGeometry(0.060, 16, 16), camSphMat);
180
  scene.add(camSph);
181
 
182
+ var barrelMat = new THREE.MeshBasicMaterial({ color: 0x1e2d3a });
183
+ var barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.020, 0.030, 0.10, 8), barrelMat);
 
184
  scene.add(barrel);
185
 
186
+ /* ── beam ── */
187
+ var beamGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
188
+ var beamMat = new THREE.LineBasicMaterial({ color: 0xffcc00, transparent: true, opacity: 0.45 });
189
+ scene.add(new THREE.Line(beamGeo, beamMat));
190
+
191
+ /* ── lights ── */
192
+ scene.add(new THREE.AmbientLight(0xffffff, 1));
193
+
194
+ /* ── state ── */
195
+ var cAz = 0, cEl = 0, cDi = 1.0;
196
+ var tAz = 0, tEl = 0, tDi = 1.0;
197
+ var lastUri = '';
198
+
199
+ var AZ = {0:'front view',45:'front-right quarter view',90:'right side view',
200
+ 135:'back-right quarter view',180:'back view',225:'back-left quarter view',
201
+ 270:'left side view',315:'front-left quarter view'};
202
+ var EL = {'-30':'low-angle shot','0':'eye-level shot','30':'elevated shot','60':'high-angle shot'};
203
+ var DI = {'0.6':'close-up','1':'medium shot','1.8':'wide shot'};
204
+
205
+ function snapV(v, opts) { return opts.reduce(function(a,b){ return Math.abs(b-v)<Math.abs(a-v)?b:a; }); }
 
 
206
 
207
  function buildPrompt(az, el, di) {
208
+ var saz = snapV(az,[0,45,90,135,180,225,270,315]);
209
+ var sel = snapV(el,[-30,0,30,60]);
210
+ var sdi = snapV(di,[0.6,1.0,1.8]);
211
+ var dk = sdi===1.0?'1':sdi.toFixed(1);
212
+ return '<sks> ' + AZ[saz] + ', ' + EL[sel+''] + ', ' + DI[dk];
213
  }
214
 
215
+ var ELC = {'-30':0x4fc3f7,'0':0x69f0ae,'30':0xffb74d,'60':0xef5350};
216
 
217
  function getSlider(id, def) {
218
  var el = document.getElementById(id);
 
221
  return inp ? parseFloat(inp.value) : def;
222
  }
223
 
224
+ function getImageUri() {
225
+ var el = document.getElementById('img_uri_store');
226
+ if (!el) return '';
227
+ var ta = el.querySelector('textarea');
228
+ return ta ? ta.value : '';
229
+ }
230
+
231
+ var loader = new THREE.TextureLoader();
232
+
233
+ function updateScene(az, el, di) {
234
  var azR = az * Math.PI / 180;
235
  var elR = el * Math.PI / 180;
236
  var x = di * Math.sin(azR) * Math.cos(elR);
 
239
 
240
  camSph.position.set(x, y, z);
241
 
 
242
  barrel.position.set(x, y, z);
243
  var dir = new THREE.Vector3(-x, -y, -z).normalize();
244
+ barrel.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir);
245
+ barrel.translateY(0.04);
246
+
247
+ var bpos = beamGeo.attributes.position;
248
+ bpos.setXYZ(0, x, y, z);
249
+ bpos.setXYZ(1, 0, subMesh.position.y, 0);
250
+ bpos.needsUpdate = true;
251
+
252
+ var sel = snapV(el,[-30,0,30,60]);
253
+ var col = ELC[sel+''] || 0x69f0ae;
 
 
 
254
  camSphMat.color.setHex(col);
255
  beamMat.color.setHex(col);
256
 
257
  promptEl.textContent = buildPrompt(az, el, di);
258
  }
259
 
260
+ /* ── animate ── */
 
 
261
  function animate() {
262
  requestAnimationFrame(animate);
263
 
264
+ tAz = getSlider('az_slider', 0);
265
+ tEl = getSlider('el_slider', 0);
266
+ tDi = getSlider('di_slider', 1.0);
267
+
268
+ var k = 0.09;
269
+ cAz += (tAz - cAz) * k;
270
+ cEl += (tEl - cEl) * k;
271
+ cDi += (tDi - cDi) * k;
272
+
273
+ updateScene(cAz, cEl, cDi);
274
+
275
+ /* update subject texture when image changes */
276
+ var uri = getImageUri();
277
+ if (uri && uri !== lastUri) {
278
+ lastUri = uri;
279
+ loader.load(uri, function(tex) {
280
+ tex.encoding = THREE.sRGBEncoding;
281
+ subMat.map = tex;
282
+ subMat.color.setHex(0xffffff);
283
+ subMat.needsUpdate = true;
284
+ });
285
+ }
286
+
287
+ renderer.render(scene, vcam);
288
  }
289
  animate();
290
  })();
 
294
  # ── UI Gradio ─────────────────────────────────────────────────────────────────
295
 
296
  with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
297
+ gr.Markdown("""
 
298
  # 🎥 Angle Studio
299
  **Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
300
  *Change the camera angle of any image — 96 precise poses*
301
+ """)
 
302
 
303
  with gr.Row():
304
+ # ── Colonne gauche : contrôles ──
305
  with gr.Column(scale=1):
306
  input_image = gr.Image(label="Image source / Source image", type="pil")
307
 
308
+ # Textbox caché passe l'image au viewer Three.js
309
+ img_uri_store = gr.Textbox(visible=False, elem_id="img_uri_store")
310
 
311
+ gr.Markdown("### 📷 Contrôle caméra / Camera Control")
312
  azimuth_slider = gr.Slider(
313
  minimum=0, maximum=315, step=45, value=0, elem_id="az_slider",
314
+ label="Azimut (0°=front · 90°=right · 180°=back · 270°=left)"
315
  )
316
  elevation_slider = gr.Slider(
317
  minimum=-30, maximum=60, step=30, value=0, elem_id="el_slider",
318
+ label="Élévation (-30°=bas · 0°=eye-level · 60°=haut)"
319
  )
320
  distance_slider = gr.Slider(
321
  minimum=0.6, maximum=1.8, step=0.6, value=1.0, elem_id="di_slider",
322
+ label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
323
  )
324
 
325
  prompt_preview = gr.Textbox(
326
+ label="Prompt", interactive=False,
327
+ value="<sks> front view, eye-level shot, medium shot"
 
328
  )
329
 
330
  with gr.Row():
331
  seed_input = gr.Number(label="Seed", value=0, precision=0)
332
+ randomize = gr.Checkbox(label="Random seed", value=True)
333
 
334
  generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
335
 
336
+ # ── Colonne droite : viewer + résultat ──
337
  with gr.Column(scale=1):
338
  gr.HTML(VIEWER_HTML)
339
  output_image = gr.Image(label="Résultat / Result", type="pil")
340
+ output_seed = gr.Number(label="Seed utilisé", interactive=False)
341
 
342
  gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
343
+ gallery = gr.Gallery(columns=4, height=260)
344
  session_images = gr.State([])
345
 
346
+ sliders = [azimuth_slider, elevation_slider, distance_slider]
347
+
348
+ # Image uploadée → data URI dans le store caché
349
+ input_image.change(fn=image_to_uri, inputs=input_image, outputs=img_uri_store)
350
+
351
+ # Sliders → prompt preview
352
+ for s in sliders:
353
+ s.change(fn=update_prompt, inputs=sliders, outputs=prompt_preview)
354
 
355
  def run_and_append(image, az, el, di, seed, rand, history):
356
  result, used_seed, prompt = infer(image, az, el, di, seed, rand)
 
359
 
360
  generate_btn.click(
361
  fn=run_and_append,
362
+ inputs=[input_image, *sliders, seed_input, randomize, session_images],
363
  outputs=[output_image, output_seed, session_images, gallery],
364
  )
365