ArtelTaleb commited on
Commit
ae4e876
·
verified ·
1 Parent(s): 8cbd237

feat: 3D Three.js orbit viewer — camera marker moves live on sliders

Browse files
Files changed (1) hide show
  1. app.py +223 -146
app.py CHANGED
@@ -1,11 +1,6 @@
1
  import random
2
  import base64
3
  import io
4
- import numpy as np
5
- import matplotlib
6
- matplotlib.use("Agg")
7
- import matplotlib.pyplot as plt
8
- import matplotlib.patches as mpatches
9
  import fal_client
10
  import gradio as gr
11
  from PIL import Image
@@ -26,11 +21,6 @@ ELEVATION_NAMES = {
26
  }
27
  DISTANCE_NAMES = {0.6: "close-up", 1.0: "medium shot", 1.8: "wide shot"}
28
 
29
- # Couleur par élévation
30
- ELEVATION_COLORS = {-30: "#4fc3f7", 0: "#69f0ae", 30: "#ffb74d", 60: "#ef5350"}
31
- ELEVATION_LABELS = {-30: "⬇ low", 0: "➡ eye", 30: "↗ high", 60: "⬆ top"}
32
-
33
- # ── Helpers ───────────────────────────────────────────────────────────────────
34
 
35
  def snap_to_nearest(value, options):
36
  return min(options, key=lambda x: abs(x - value))
@@ -43,116 +33,21 @@ def build_camera_prompt(azimuth: float, elevation: float, distance: float) -> st
43
  return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
44
 
45
 
 
 
 
 
46
  def pil_to_data_uri(img: Image.Image) -> str:
47
  buf = io.BytesIO()
48
  img.save(buf, format="PNG")
49
  b64 = base64.b64encode(buf.getvalue()).decode()
50
  return f"data:image/png;base64,{b64}"
51
 
52
- # ── Diagramme caméra ──────────────────────────────────────────────────────────
53
-
54
- def draw_camera_diagram(azimuth: float, elevation: float, distance: float):
55
- az = snap_to_nearest(azimuth, AZIMUTHS)
56
- el = snap_to_nearest(elevation, ELEVATIONS)
57
- di = snap_to_nearest(distance, DISTANCES)
58
-
59
- # Azimut en coordonnées polaires : 0° = haut (front), sens horaire
60
- # matplotlib polar : 0 = droite, sens anti-horaire → convertir
61
- az_rad = np.radians(90 - az)
62
-
63
- # Rayon normalisé selon distance (3 orbites)
64
- r_map = {0.6: 0.35, 1.0: 0.60, 1.8: 0.88}
65
- r = r_map[di]
66
-
67
- cam_color = ELEVATION_COLORS[el]
68
- prompt = build_camera_prompt(azimuth, elevation, distance)
69
-
70
- BG = "#111118"
71
- GRID = "#1e1e30"
72
- ORBIT = "#2a2a45"
73
- LABEL = "#6666aa"
74
- SUBJECT = "#ffffff"
75
- BEAM = cam_color
76
-
77
- fig = plt.figure(figsize=(5.2, 5.8), facecolor=BG)
78
- ax = fig.add_axes([0.08, 0.12, 0.84, 0.84], projection="polar", facecolor=BG)
79
-
80
- theta = np.linspace(0, 2 * np.pi, 360)
81
-
82
- # Orbites des 3 distances
83
- orbit_styles = {0.35: ("--", 0.4, "close-up"), 0.60: ("-", 0.6, "medium"), 0.88: ("--", 0.4, "wide")}
84
- for rv, (ls, alpha, dlabel) in orbit_styles.items():
85
- ax.plot(theta, [rv] * 360, ls, color=ORBIT, linewidth=1, alpha=alpha, zorder=1)
86
- ax.text(np.radians(15), rv + 0.03, dlabel, color=LABEL, fontsize=6.5,
87
- ha="left", va="bottom", fontfamily="monospace")
88
-
89
- # Etiquettes azimut (8 directions)
90
- az_short = {0: "FRONT", 45: "FR", 90: "RIGHT", 135: "BR",
91
- 180: "BACK", 225: "BL", 270: "LEFT", 315: "FL"}
92
- for a, short in az_short.items():
93
- a_rad = np.radians(90 - a)
94
- ax.text(a_rad, 1.04, short, color=LABEL, fontsize=6, ha="center", va="center",
95
- fontfamily="monospace", fontweight="bold")
96
-
97
- # Rayon guide vers caméra
98
- ax.plot([az_rad, az_rad], [0, r], color=BEAM, linewidth=1, alpha=0.4, zorder=2)
99
-
100
- # Sujet au centre
101
- ax.plot(0, 0, "s", color=SUBJECT, markersize=11, zorder=5)
102
- ax.text(np.radians(45), 0.08, "SUBJECT", color=SUBJECT, fontsize=6,
103
- ha="center", va="center", fontfamily="monospace", alpha=0.7)
104
-
105
- # Position caméra — cercle fond + cercle coloré (élévation)
106
- ax.plot(az_rad, r, "o", color=BG, markersize=18, zorder=6)
107
- ax.plot(az_rad, r, "o", color=BEAM, markersize=14, zorder=7, alpha=0.9)
108
- ax.text(az_rad, r, "📷", fontsize=9, ha="center", va="center", zorder=8)
109
-
110
- # Configuration axes
111
- ax.set_ylim(0, 1.15)
112
- ax.set_theta_zero_location("N")
113
- ax.set_theta_direction(-1)
114
- ax.set_xticks([])
115
- ax.set_yticks([])
116
- ax.spines["polar"].set_visible(False)
117
-
118
- # Légende élévation
119
- legend_handles = [
120
- mpatches.Patch(color=ELEVATION_COLORS[e], label=f"{ELEVATION_LABELS[e]} ({e}°)")
121
- for e in ELEVATIONS
122
- ]
123
- ax.legend(handles=legend_handles, loc="lower center", bbox_to_anchor=(0.5, -0.18),
124
- ncol=4, frameon=False, fontsize=7,
125
- labelcolor="white", handlelength=1.0)
126
-
127
- # Prompt en bas
128
- fig.text(0.5, 0.02, prompt, ha="center", fontsize=8, color="#00e676",
129
- fontfamily="monospace", style="italic",
130
- bbox=dict(boxstyle="round,pad=0.3", facecolor="#0d1117", edgecolor="#00e676", alpha=0.8))
131
-
132
- fig.patch.set_facecolor(BG)
133
- return fig
134
-
135
-
136
- def update_diagram(azimuth, elevation, distance):
137
- return draw_camera_diagram(azimuth, elevation, distance)
138
-
139
-
140
- def update_prompt_preview(azimuth, elevation, distance):
141
- return build_camera_prompt(azimuth, elevation, distance)
142
-
143
  # ── Inférence fal.ai ──────────────────────────────────────────────────────────
144
 
145
- def infer(
146
- image: Image.Image,
147
- azimuth: float,
148
- elevation: float,
149
- distance: float,
150
- seed: int,
151
- randomize_seed: bool,
152
- ):
153
  if image is None:
154
  raise gr.Error("Veuillez uploader une image source / Please upload a source image")
155
-
156
  if randomize_seed:
157
  seed = random.randint(0, 2**31 - 1)
158
 
@@ -168,22 +63,223 @@ def infer(
168
  "image_size": {"width": 1024, "height": 1024},
169
  "num_inference_steps": 4,
170
  "guidance_scale": 1.0,
171
- "loras": [
172
- {
173
- "path": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA",
174
- "scale": 1.0,
175
- }
176
- ],
177
  },
178
  )
179
 
180
- image_url_out = result["images"][0]["url"]
181
  import urllib.request
182
- with urllib.request.urlopen(image_url_out) as resp:
183
  out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
184
 
185
  return out_img, seed, prompt
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  # ── UI Gradio ─────────────────────────────────────────────────────────────────
188
 
189
  with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
@@ -191,33 +287,31 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
191
  """
192
  # 🎥 Angle Studio
193
  **Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
194
-
195
  *Change the camera angle of any image — 96 precise poses*
196
  """
197
  )
198
 
199
  with gr.Row():
200
- # Colonne gauche — contrôles
201
  with gr.Column(scale=1):
202
  input_image = gr.Image(label="Image source / Source image", type="pil")
203
 
204
  gr.Markdown("### 📷 Contrôle caméra / Camera Control")
205
 
206
  azimuth_slider = gr.Slider(
207
- minimum=0, maximum=315, step=45, value=0,
208
- label="Azimut / Azimuth — rotation horizontale (0°=front · 180°=back)"
209
  )
210
  elevation_slider = gr.Slider(
211
- minimum=-30, maximum=60, step=30, value=0,
212
- label="Élévation / Elevation — angle vertical (-30°=bas · 60°=haut)"
213
  )
214
  distance_slider = gr.Slider(
215
- minimum=0.6, maximum=1.8, step=0.6, value=1.0,
216
  label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
217
  )
218
 
219
  prompt_preview = gr.Textbox(
220
- label="Prompt envoyé / Sent prompt",
221
  value="<sks> front view, eye-level shot, medium shot",
222
  interactive=False,
223
  )
@@ -228,34 +322,18 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
228
 
229
  generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
230
 
231
- # Colonne droite — diagramme + résultat
232
  with gr.Column(scale=1):
233
- camera_diagram = gr.Plot(label="Position caméra / Camera position", show_label=True)
234
  output_image = gr.Image(label="Résultat / Result", type="pil")
235
  output_seed = gr.Number(label="Seed utilisé / Used seed", interactive=False)
236
 
237
  gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
238
  gallery = gr.Gallery(label="Générations / Generations", columns=4, height=280)
239
-
240
  session_images = gr.State([])
241
 
242
- # Mise à jour diagramme + prompt en temps réel
243
  slider_inputs = [azimuth_slider, elevation_slider, distance_slider]
244
-
245
  for slider in slider_inputs:
246
- slider.change(
247
- fn=update_diagram,
248
- inputs=slider_inputs,
249
- outputs=camera_diagram,
250
- )
251
- slider.change(
252
- fn=update_prompt_preview,
253
- inputs=slider_inputs,
254
- outputs=prompt_preview,
255
- )
256
-
257
- # Diagramme initial
258
- demo.load(fn=update_diagram, inputs=slider_inputs, outputs=camera_diagram)
259
 
260
  def run_and_append(image, az, el, di, seed, rand, history):
261
  result, used_seed, prompt = infer(image, az, el, di, seed, rand)
@@ -264,8 +342,7 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
264
 
265
  generate_btn.click(
266
  fn=run_and_append,
267
- inputs=[input_image, azimuth_slider, elevation_slider, distance_slider,
268
- seed_input, randomize, session_images],
269
  outputs=[output_image, output_seed, session_images, gallery],
270
  )
271
 
 
1
  import random
2
  import base64
3
  import io
 
 
 
 
 
4
  import fal_client
5
  import gradio as gr
6
  from PIL import Image
 
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))
 
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
 
 
63
  "image_size": {"width": 1024, "height": 1024},
64
  "num_inference_steps": 4,
65
  "guidance_scale": 1.0,
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>
89
+
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);
213
+ if (!el) return def;
214
+ var inp = el.querySelector('input[type="range"]');
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);
222
+ var y = di * Math.sin(elR);
223
+ var z = di * Math.cos(azR) * Math.cos(elR);
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
+ })();
280
+ </script>
281
+ """
282
+
283
  # ── UI Gradio ─────────────────────────────────────────────────────────────────
284
 
285
  with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
 
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
  )
 
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
 
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