dquarel commited on
Commit
980ed65
·
1 Parent(s): eeb4b82

added functionality for janus models

Browse files
Files changed (3) hide show
  1. app.py +204 -18
  2. run_local.sh +2 -0
  3. vidshow.py +336 -0
app.py CHANGED
@@ -6,9 +6,10 @@ from pathlib import Path
6
 
7
  import gradio as gr
8
  from huggingface_hub import HfApi, hf_hub_download
9
- from vidshow import vidshow_from_png_bytes
10
 
11
  ACTION_PROBS_FILES = ("action_probs.tar.gz", "action_probs.zip")
 
12
 
13
  JAXGMG_COLLECTION_SLUG = "davidquarel/jaxgmg-69528c4f23d35d7de3eecfa1"
14
  DEFAULT_REPO = "davidquarel/jaxgmg_3phase_seed"
@@ -51,8 +52,11 @@ def get_checkpoint_list(repo_id: str) -> tuple[str, ...]:
51
  local_path = get_local_path(repo_id)
52
  if USE_LOCAL and local_path.exists():
53
  checkpoints = set()
54
- for ap_file in ACTION_PROBS_FILES:
55
- for found in local_path.rglob(ap_file):
 
 
 
56
  if found.stat().st_size > 1000:
57
  rel_path = found.parent.relative_to(local_path)
58
  checkpoints.add(str(rel_path))
@@ -61,13 +65,37 @@ def get_checkpoint_list(repo_id: str) -> tuple[str, ...]:
61
  files = get_repo_files(repo_id)
62
  checkpoints = set()
63
  for f in files:
64
- if any(f.endswith(ap) for ap in ACTION_PROBS_FILES):
 
65
  dir_path = f.rsplit("/", 1)[0] if "/" in f else ""
66
  if dir_path:
67
  checkpoints.add(dir_path)
68
  return tuple(sorted(checkpoints))
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def update_checkpoints(repo_id: str):
72
  checkpoints = list(get_checkpoint_list(repo_id))
73
  return gr.Dropdown(
@@ -76,6 +104,58 @@ def update_checkpoints(repo_id: str):
76
  )
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def load_action_probs(repo_id: str, checkpoint: str, progress=gr.Progress()) -> str:
80
  if not checkpoint:
81
  return "<div style='padding:40px;text-align:center;color:#f88;'>Please select a checkpoint</div>"
@@ -111,8 +191,35 @@ def load_action_probs(repo_id: str, checkpoint: str, progress=gr.Progress()) ->
111
  if archive_path is None or archive_filename is None:
112
  return "<div style='padding:40px;text-align:center;color:#f88;'>No action_probs archive found</div>"
113
 
114
- progress(0.2, desc="Reading archive...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
 
 
 
 
 
 
 
 
 
 
 
116
  png_bytes_list: list[bytes] = []
117
  frame_names: list[str] = []
118
 
@@ -122,7 +229,7 @@ def load_action_probs(repo_id: str, checkpoint: str, progress=gr.Progress()) ->
122
  (m for m in tar.getmembers() if m.name.endswith(".png")),
123
  key=lambda m: m.name
124
  )
125
- total = len(members)
126
  last_update = 0
127
  for i, member in enumerate(members):
128
  f = tar.extractfile(member)
@@ -130,33 +237,102 @@ def load_action_probs(repo_id: str, checkpoint: str, progress=gr.Progress()) ->
130
  png_bytes_list.append(f.read())
131
  name = member.name.rsplit("/", 1)[-1].replace(".png", "")
132
  frame_names.append(name)
133
- if i - last_update >= 100:
134
- progress(0.2 + 0.6 * (i / total), desc=f"Reading ({i}/{total})...")
135
  last_update = i
136
  else:
137
  with zipfile.ZipFile(archive_path, "r") as zf:
138
  members = sorted(n for n in zf.namelist() if n.endswith(".png"))
139
- total = len(members)
140
  last_update = 0
141
  for i, name in enumerate(members):
142
  png_bytes_list.append(zf.read(name))
143
  frame_name = name.rsplit("/", 1)[-1].replace(".png", "")
144
  frame_names.append(frame_name)
145
- if i - last_update >= 100:
146
- progress(0.2 + 0.6 * (i / total), desc=f"Reading ({i}/{total})...")
147
  last_update = i
148
 
149
- progress(0.85, desc="Building player...")
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  title = f"{repo_id.split('/')[-1]} / {checkpoint}"
152
- html = vidshow_from_png_bytes(
153
- png_bytes_list, frame_names=frame_names, fps=12, loop=True, title=title
 
 
 
 
 
 
 
 
 
 
 
 
154
  )
155
 
156
  progress(1.0, desc="Done!")
157
  return html
158
 
159
 
 
 
 
 
 
 
160
  def create_demo():
161
  repos = list(get_collection_repos())
162
  initial_checkpoints = list(get_checkpoint_list(DEFAULT_REPO))
@@ -177,6 +353,11 @@ def create_demo():
177
  filterable=True,
178
  scale=2,
179
  )
 
 
 
 
 
180
  load_btn = gr.Button("Load", variant="primary", scale=1)
181
 
182
  output_html = gr.HTML(
@@ -189,14 +370,19 @@ def create_demo():
189
  )
190
 
191
  repo_dropdown.change(
192
- fn=update_checkpoints,
193
- inputs=[repo_dropdown],
 
 
 
 
 
194
  outputs=[checkpoint_dropdown],
195
  )
196
 
197
  load_btn.click(
198
- fn=load_action_probs,
199
- inputs=[repo_dropdown, checkpoint_dropdown],
200
  outputs=[output_html],
201
  )
202
 
 
6
 
7
  import gradio as gr
8
  from huggingface_hub import HfApi, hf_hub_download
9
+ from vidshow import vidshow_dual_from_png_bytes, vidshow_from_png_bytes
10
 
11
  ACTION_PROBS_FILES = ("action_probs.tar.gz", "action_probs.zip")
12
+ ACTION_PROBS_SUFFIXES = (".tar.gz", ".zip")
13
 
14
  JAXGMG_COLLECTION_SLUG = "davidquarel/jaxgmg-69528c4f23d35d7de3eecfa1"
15
  DEFAULT_REPO = "davidquarel/jaxgmg_3phase_seed"
 
52
  local_path = get_local_path(repo_id)
53
  if USE_LOCAL and local_path.exists():
54
  checkpoints = set()
55
+ for found in local_path.rglob("*"):
56
+ if not found.is_file():
57
+ continue
58
+ name = found.name
59
+ if "action_probs" in name and name.endswith(ACTION_PROBS_SUFFIXES):
60
  if found.stat().st_size > 1000:
61
  rel_path = found.parent.relative_to(local_path)
62
  checkpoints.add(str(rel_path))
 
65
  files = get_repo_files(repo_id)
66
  checkpoints = set()
67
  for f in files:
68
+ name = f.rsplit("/", 1)[-1]
69
+ if "action_probs" in name and name.endswith(ACTION_PROBS_SUFFIXES):
70
  dir_path = f.rsplit("/", 1)[0] if "/" in f else ""
71
  if dir_path:
72
  checkpoints.add(dir_path)
73
  return tuple(sorted(checkpoints))
74
 
75
 
76
+ def get_checkpoint_files(repo_id: str, checkpoint: str) -> tuple[str, ...]:
77
+ """List files within a checkpoint directory (local or remote)."""
78
+ local_path = get_local_path(repo_id)
79
+ if USE_LOCAL and local_path.exists():
80
+ checkpoint_dir = local_path / checkpoint
81
+ if not checkpoint_dir.exists():
82
+ return ()
83
+ return tuple(str(p.name) for p in checkpoint_dir.iterdir() if p.is_file())
84
+ files = get_repo_files(repo_id)
85
+ prefix = f"{checkpoint}/"
86
+ return tuple(f[len(prefix):] for f in files if f.startswith(prefix) and "/" not in f[len(prefix):])
87
+
88
+
89
+ def has_janus_eval(repo_id: str, checkpoint: str) -> bool:
90
+ files = set(get_checkpoint_files(repo_id, checkpoint))
91
+ if "janus_eval.json" in files or "janus_eval.jsonl" in files:
92
+ return True
93
+ return (
94
+ "action_probs_model1.tar.gz" in files
95
+ and "action_probs_model2.tar.gz" in files
96
+ )
97
+
98
+
99
  def update_checkpoints(repo_id: str):
100
  checkpoints = list(get_checkpoint_list(repo_id))
101
  return gr.Dropdown(
 
104
  )
105
 
106
 
107
+ def update_checkpoints_filtered(repo_id: str, janus_only: bool):
108
+ checkpoints = list(get_checkpoint_list(repo_id))
109
+ if janus_only:
110
+ checkpoints = [ckpt for ckpt in checkpoints if has_janus_eval(repo_id, ckpt)]
111
+ return gr.Dropdown(
112
+ choices=checkpoints,
113
+ value=checkpoints[0] if checkpoints else None,
114
+ )
115
+
116
+
117
+ def list_action_prob_archives(repo_id: str, checkpoint: str) -> list[tuple[str, str]]:
118
+ """Return list of (archive_path, filename) for action_probs archives."""
119
+ candidates: list[tuple[str, str]] = []
120
+ local_path = get_local_path(repo_id)
121
+
122
+ if USE_LOCAL and local_path.exists():
123
+ checkpoint_dir = local_path / checkpoint
124
+ if checkpoint_dir.exists():
125
+ for path in sorted(checkpoint_dir.iterdir()):
126
+ if not path.is_file():
127
+ continue
128
+ name = path.name
129
+ if "action_probs" in name and (name.endswith(".tar.gz") or name.endswith(".zip")):
130
+ if path.stat().st_size > 1000:
131
+ candidates.append((str(path), name))
132
+ return candidates
133
+
134
+ files = get_repo_files(repo_id)
135
+ prefix = f"{checkpoint}/"
136
+ for f in files:
137
+ if not f.startswith(prefix):
138
+ continue
139
+ name = f[len(prefix):]
140
+ if "/" in name:
141
+ continue
142
+ if "action_probs" in name and (name.endswith(".tar.gz") or name.endswith(".zip")):
143
+ candidates.append((f, name))
144
+ return sorted(candidates, key=lambda item: item[1])
145
+
146
+
147
+ def select_janus_archives(archives: list[tuple[str, str]]) -> list[tuple[str, str]]:
148
+ """Pick model1/model2 archives by naming convention, else first two."""
149
+ by_name = {name: (path, name) for path, name in archives}
150
+ preferred = []
151
+ for name in ("action_probs_model1.tar.gz", "action_probs_model2.tar.gz"):
152
+ if name in by_name:
153
+ preferred.append(by_name[name])
154
+ if len(preferred) == 2:
155
+ return preferred
156
+ return archives[:2]
157
+
158
+
159
  def load_action_probs(repo_id: str, checkpoint: str, progress=gr.Progress()) -> str:
160
  if not checkpoint:
161
  return "<div style='padding:40px;text-align:center;color:#f88;'>Please select a checkpoint</div>"
 
191
  if archive_path is None or archive_filename is None:
192
  return "<div style='padding:40px;text-align:center;color:#f88;'>No action_probs archive found</div>"
193
 
194
+ png_bytes_list, frame_names = read_action_probs_archive(
195
+ archive_path,
196
+ archive_filename,
197
+ progress=progress,
198
+ progress_start=0.2,
199
+ progress_span=0.6,
200
+ )
201
+
202
+ progress(0.85, desc="Building player...")
203
+
204
+ title = f"{repo_id.split('/')[-1]} / {checkpoint}"
205
+ html = vidshow_from_png_bytes(
206
+ png_bytes_list, frame_names=frame_names, fps=12, loop=True, title=title
207
+ )
208
+
209
+ progress(1.0, desc="Done!")
210
+ return html
211
 
212
+
213
+ def read_action_probs_archive(
214
+ archive_path: str,
215
+ archive_filename: str,
216
+ *,
217
+ progress: gr.Progress | None = None,
218
+ progress_start: float = 0.2,
219
+ progress_span: float = 0.6,
220
+ ) -> tuple[list[bytes], list[str]]:
221
+ if progress is not None:
222
+ progress(progress_start, desc="Reading archive...")
223
  png_bytes_list: list[bytes] = []
224
  frame_names: list[str] = []
225
 
 
229
  (m for m in tar.getmembers() if m.name.endswith(".png")),
230
  key=lambda m: m.name
231
  )
232
+ total = len(members) or 1
233
  last_update = 0
234
  for i, member in enumerate(members):
235
  f = tar.extractfile(member)
 
237
  png_bytes_list.append(f.read())
238
  name = member.name.rsplit("/", 1)[-1].replace(".png", "")
239
  frame_names.append(name)
240
+ if progress is not None and i - last_update >= 100:
241
+ progress(progress_start + progress_span * (i / total), desc=f"Reading ({i}/{total})...")
242
  last_update = i
243
  else:
244
  with zipfile.ZipFile(archive_path, "r") as zf:
245
  members = sorted(n for n in zf.namelist() if n.endswith(".png"))
246
+ total = len(members) or 1
247
  last_update = 0
248
  for i, name in enumerate(members):
249
  png_bytes_list.append(zf.read(name))
250
  frame_name = name.rsplit("/", 1)[-1].replace(".png", "")
251
  frame_names.append(frame_name)
252
+ if progress is not None and i - last_update >= 100:
253
+ progress(progress_start + progress_span * (i / total), desc=f"Reading ({i}/{total})...")
254
  last_update = i
255
 
256
+ return png_bytes_list, frame_names
257
+
258
 
259
+ def load_action_probs_janus(repo_id: str, checkpoint: str, progress=gr.Progress()) -> str:
260
+ if not checkpoint:
261
+ return "<div style='padding:40px;text-align:center;color:#f88;'>Please select a checkpoint</div>"
262
+
263
+ progress(0.05, desc="Locating files...")
264
+ archives = list_action_prob_archives(repo_id, checkpoint)
265
+
266
+ local_pairs = []
267
+ selected_archives = select_janus_archives(archives)
268
+ progress(0.1, desc="Resolving archives...")
269
+ for archive_path, name in selected_archives:
270
+ resolved_path = archive_path
271
+ if USE_LOCAL:
272
+ # If local path is missing, fall back to HF download.
273
+ if not os.path.exists(resolved_path):
274
+ resolved_path = hf_hub_download(
275
+ repo_id=repo_id,
276
+ filename=f"{checkpoint}/{name}",
277
+ repo_type="model",
278
+ )
279
+ else:
280
+ resolved_path = hf_hub_download(
281
+ repo_id=repo_id,
282
+ filename=archive_path,
283
+ repo_type="model",
284
+ )
285
+ local_pairs.append((resolved_path, name))
286
+
287
+ if len(local_pairs) < 2:
288
+ return "<div style='padding:40px;text-align:center;color:#f88;'>Need two action_probs archives for Janus</div>"
289
+
290
+ left_path, left_name = local_pairs[0]
291
+ right_path, right_name = local_pairs[1]
292
+
293
+ left_frames, left_names = read_action_probs_archive(
294
+ left_path,
295
+ left_name,
296
+ progress=progress,
297
+ progress_start=0.2,
298
+ progress_span=0.35,
299
+ )
300
+ right_frames, right_names = read_action_probs_archive(
301
+ right_path,
302
+ right_name,
303
+ progress=progress,
304
+ progress_start=0.55,
305
+ progress_span=0.35,
306
+ )
307
+
308
+ progress(0.9, desc="Building player...")
309
  title = f"{repo_id.split('/')[-1]} / {checkpoint}"
310
+ left_title = left_name.replace(".tar.gz", "").replace(".zip", "")
311
+ right_title = right_name.replace(".tar.gz", "").replace(".zip", "")
312
+ html = vidshow_dual_from_png_bytes(
313
+ left_frames,
314
+ right_frames,
315
+ left_frame_names=left_names,
316
+ right_frame_names=right_names,
317
+ fps=12,
318
+ loop=True,
319
+ title=title,
320
+ left_title=left_title,
321
+ right_title=right_title,
322
+ gap_px=28,
323
+ counter_label=checkpoint,
324
  )
325
 
326
  progress(1.0, desc="Done!")
327
  return html
328
 
329
 
330
+ def load_action_probs_any(repo_id: str, checkpoint: str, janus_only: bool, progress=gr.Progress()) -> str:
331
+ if janus_only:
332
+ return load_action_probs_janus(repo_id, checkpoint, progress=progress)
333
+ return load_action_probs(repo_id, checkpoint, progress=progress)
334
+
335
+
336
  def create_demo():
337
  repos = list(get_collection_repos())
338
  initial_checkpoints = list(get_checkpoint_list(DEFAULT_REPO))
 
353
  filterable=True,
354
  scale=2,
355
  )
356
+ janus_checkbox = gr.Checkbox(
357
+ value=False,
358
+ label="Janus model?",
359
+ scale=1,
360
+ )
361
  load_btn = gr.Button("Load", variant="primary", scale=1)
362
 
363
  output_html = gr.HTML(
 
370
  )
371
 
372
  repo_dropdown.change(
373
+ fn=update_checkpoints_filtered,
374
+ inputs=[repo_dropdown, janus_checkbox],
375
+ outputs=[checkpoint_dropdown],
376
+ )
377
+ janus_checkbox.change(
378
+ fn=update_checkpoints_filtered,
379
+ inputs=[repo_dropdown, janus_checkbox],
380
  outputs=[checkpoint_dropdown],
381
  )
382
 
383
  load_btn.click(
384
+ fn=load_action_probs_any,
385
+ inputs=[repo_dropdown, checkpoint_dropdown, janus_checkbox],
386
  outputs=[output_html],
387
  )
388
 
run_local.sh ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #!/bin/bash
2
+ JAXGMG_USE_LOCAL=1 python app.py
vidshow.py CHANGED
@@ -280,3 +280,339 @@ show(0);
280
 
281
  # Wrap in iframe - srcdoc allows scripts to run
282
  return f'<iframe srcdoc="{srcdoc_escaped}" style="width:100%;height:{iframe_height}px;border:none;border-radius:12px;"></iframe>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
  # Wrap in iframe - srcdoc allows scripts to run
282
  return f'<iframe srcdoc="{srcdoc_escaped}" style="width:100%;height:{iframe_height}px;border:none;border-radius:12px;"></iframe>'
283
+
284
+
285
+ def vidshow_dual_from_png_bytes(
286
+ left_png_bytes: list[bytes],
287
+ right_png_bytes: list[bytes],
288
+ *,
289
+ left_frame_names: list[str] | None = None,
290
+ right_frame_names: list[str] | None = None,
291
+ fps: int = 12,
292
+ loop: bool = True,
293
+ title: str | None = None,
294
+ left_title: str | None = None,
295
+ right_title: str | None = None,
296
+ gap_px: int = 24,
297
+ counter_label: str | None = None,
298
+ ) -> str:
299
+ """Dual vidshow with linked frame index across two sequences."""
300
+ if not left_png_bytes or not right_png_bytes:
301
+ raise ValueError("Both left and right frame lists must be non-empty.")
302
+
303
+ # Get image dimensions from first frames
304
+ with Image.open(io.BytesIO(left_png_bytes[0])) as img:
305
+ left_width, left_height = img.size
306
+ with Image.open(io.BytesIO(right_png_bytes[0])) as img:
307
+ right_width, right_height = img.size
308
+
309
+ def encode_one(png_bytes: bytes) -> str:
310
+ return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
311
+
312
+ with ThreadPoolExecutor(max_workers=8) as pool:
313
+ left_urls = list(pool.map(encode_one, left_png_bytes))
314
+ right_urls = list(pool.map(encode_one, right_png_bytes))
315
+
316
+ left_n = len(left_urls)
317
+ right_n = len(right_urls)
318
+ total_n = max(left_n, right_n)
319
+ if left_frame_names is None:
320
+ left_frame_names = [str(i) for i in range(left_n)]
321
+ if right_frame_names is None:
322
+ right_frame_names = [str(i) for i in range(right_n)]
323
+
324
+ frames_left_json = json.dumps(left_urls)
325
+ frames_right_json = json.dumps(right_urls)
326
+ names_left_json = json.dumps(left_frame_names)
327
+ names_right_json = json.dumps(right_frame_names)
328
+ title_escaped = html_module.escape(title) if title else "Video"
329
+ left_title_escaped = html_module.escape(left_title) if left_title else "Model A"
330
+ right_title_escaped = html_module.escape(right_title) if right_title else "Model B"
331
+ counter_label_escaped = html_module.escape(counter_label) if counter_label else title_escaped
332
+ loop_js = "true" if loop else "false"
333
+ iframe_height = max(left_height, right_height) + 200
334
+ gap_px = max(0, int(gap_px))
335
+
336
+ inner_html = f"""<!DOCTYPE html>
337
+ <html>
338
+ <head>
339
+ <meta charset="utf-8">
340
+ <style>
341
+ *{{box-sizing:border-box;}}
342
+ html,body{{margin:0;padding:0;}}
343
+ body{{
344
+ padding:12px;
345
+ background:#1a1a2e;
346
+ font-family:sans-serif;
347
+ }}
348
+ body.fullscreen{{
349
+ display:flex;
350
+ flex-direction:column;
351
+ height:100vh;
352
+ }}
353
+ .title{{font-weight:600;color:#e0e0e0;margin-bottom:8px;font-size:1.1em;text-align:center;}}
354
+ .dual-row{{display:flex;gap:{gap_px}px;justify-content:center;align-items:flex-start;}}
355
+ .panel{{display:flex;flex-direction:column;align-items:center;}}
356
+ .panel-title{{color:#c8c8d8;font-size:0.95em;margin-bottom:6px;}}
357
+ .img-container{{
358
+ overflow:hidden;
359
+ border-radius:8px;
360
+ box-shadow:0 4px 20px rgba(0,0,0,.4);
361
+ cursor:grab;
362
+ background:#0f0f1a;
363
+ position:relative;
364
+ display:inline-block;
365
+ }}
366
+ body.fullscreen .dual-row{{
367
+ flex:1;
368
+ align-items:stretch;
369
+ }}
370
+ body.fullscreen .panel{{
371
+ flex:1;
372
+ min-width:0;
373
+ }}
374
+ body.fullscreen .img-container{{
375
+ flex:1;
376
+ min-height:0;
377
+ display:block;
378
+ width:100%;
379
+ height:100%;
380
+ }}
381
+ .img-container.dragging{{cursor:grabbing;}}
382
+ img{{
383
+ display:block;
384
+ image-rendering:pixelated;
385
+ user-select:none;
386
+ -webkit-user-drag:none;
387
+ transform-origin:0 0;
388
+ }}
389
+ body.fullscreen img{{
390
+ position:absolute;
391
+ top:0;left:0;
392
+ }}
393
+ .controls{{display:flex;gap:8px;align-items:center;margin-top:12px;}}
394
+ button{{padding:6px 12px;border-radius:6px;border:none;background:#4a4a6a;color:#fff;cursor:pointer;font-size:1em;}}
395
+ button:hover{{background:#5a5a7a;}}
396
+ button.primary{{background:#6366f1;}}
397
+ button.primary:hover{{background:#7376f2;}}
398
+ input[type=range]{{flex:1;accent-color:#6366f1;}}
399
+ .label{{min-width:12ch;text-align:right;font-variant-numeric:tabular-nums;color:#e0e0e0;font-size:0.9em;}}
400
+ .fps-row{{display:flex;align-items:center;gap:8px;margin-top:10px;}}
401
+ .fps-label{{font-size:0.9em;color:#a0a0a0;}}
402
+ .fps-value{{min-width:3ch;text-align:right;color:#e0e0e0;}}
403
+ .hint{{font-size:0.8em;color:#666;margin-top:8px;text-align:center;}}
404
+ </style>
405
+ </head>
406
+ <body>
407
+ <div class="title">{title_escaped}</div>
408
+ <div class="dual-row">
409
+ <div class="panel">
410
+ <div class="panel-title">{left_title_escaped}</div>
411
+ <div class="img-container" id="leftContainer">
412
+ <img id="leftImg" src="{left_urls[0]}">
413
+ </div>
414
+ </div>
415
+ <div class="panel">
416
+ <div class="panel-title">{right_title_escaped}</div>
417
+ <div class="img-container" id="rightContainer">
418
+ <img id="rightImg" src="{right_urls[0]}">
419
+ </div>
420
+ </div>
421
+ </div>
422
+ <div class="controls">
423
+ <button id="back">◀</button>
424
+ <button id="toggle" class="primary">⏯️</button>
425
+ <button id="fwd">▶</button>
426
+ <button id="reset" title="Reset pan/zoom">⟲</button>
427
+ <button id="fullscreen" title="Fullscreen (Esc to exit)">⛶</button>
428
+ <input type="range" id="slider" min="0" max="{total_n - 1}" value="0" step="1">
429
+ <span id="label" class="label"></span>
430
+ </div>
431
+ <div class="fps-row">
432
+ <span class="fps-label">FPS:</span>
433
+ <input type="range" id="fps" min="1" max="60" value="{fps}" step="1">
434
+ <span id="fpsLabel" class="fps-value">{fps}</span>
435
+ </div>
436
+ <div class="hint">Drag to pan • Scroll to zoom • R=reset • Fullscreen for more space</div>
437
+ <script>
438
+ var framesLeft={frames_left_json};
439
+ var framesRight={frames_right_json};
440
+ var namesLeft={names_left_json};
441
+ var namesRight={names_right_json};
442
+ var loopEnabled={loop_js};
443
+ var playing=false,timer=null,fps={fps},idx=0;
444
+
445
+ var leftImg=document.getElementById("leftImg");
446
+ var rightImg=document.getElementById("rightImg");
447
+ var leftContainer=document.getElementById("leftContainer");
448
+ var rightContainer=document.getElementById("rightContainer");
449
+ var slider=document.getElementById("slider");
450
+ var label=document.getElementById("label");
451
+ var toggle=document.getElementById("toggle");
452
+ var fpsSlider=document.getElementById("fps");
453
+ var fpsLabelEl=document.getElementById("fpsLabel");
454
+ var resetBtn=document.getElementById("reset");
455
+ var fullscreenBtn=document.getElementById("fullscreen");
456
+
457
+ // Pan/zoom state per panel
458
+ var panState={{
459
+ left: {{drag:false,startX:0,startY:0,panX:0,panY:0,scale:1,imgW:0,imgH:0}},
460
+ right: {{drag:false,startX:0,startY:0,panX:0,panY:0,scale:1,imgW:0,imgH:0}},
461
+ }};
462
+
463
+ function updateTransform(side){{
464
+ var s=panState[side];
465
+ var img=side==="left"?leftImg:rightImg;
466
+ img.style.transform="translate("+s.panX+"px,"+s.panY+"px) scale("+s.scale+")";
467
+ }}
468
+
469
+ function resetView(){{
470
+ ["left","right"].forEach(function(side){{
471
+ var s=panState[side];
472
+ s.panX=0;s.panY=0;s.scale=1;
473
+ updateTransform(side);
474
+ }});
475
+ }}
476
+
477
+ function fitToView(side){{
478
+ var s=panState[side];
479
+ var img=side==="left"?leftImg:rightImg;
480
+ var container=side==="left"?leftContainer:rightContainer;
481
+ var cw=container.clientWidth;
482
+ var ch=container.clientHeight;
483
+ if(s.imgW===0||s.imgH===0)return;
484
+ var scaleX=cw/s.imgW;
485
+ var scaleY=ch/s.imgH;
486
+ s.scale=Math.min(scaleX,scaleY,1);
487
+ s.panX=(cw-s.imgW*s.scale)/2;
488
+ s.panY=(ch-s.imgH*s.scale)/2;
489
+ updateTransform(side);
490
+ }}
491
+
492
+ leftImg.onload=function(){{
493
+ panState.left.imgW=leftImg.naturalWidth;
494
+ panState.left.imgH=leftImg.naturalHeight;
495
+ }};
496
+ rightImg.onload=function(){{
497
+ panState.right.imgW=rightImg.naturalWidth;
498
+ panState.right.imgH=rightImg.naturalHeight;
499
+ }};
500
+
501
+ function attachPanZoom(container, side){{
502
+ container.addEventListener("mousedown",function(e){{
503
+ if(e.button!==0)return;
504
+ var s=panState[side];
505
+ s.drag=true;
506
+ s.startX=e.clientX-s.panX;
507
+ s.startY=e.clientY-s.panY;
508
+ container.classList.add("dragging");
509
+ e.preventDefault();
510
+ }});
511
+
512
+ document.addEventListener("mousemove",function(e){{
513
+ var s=panState[side];
514
+ if(!s.drag)return;
515
+ s.panX=e.clientX-s.startX;
516
+ s.panY=e.clientY-s.startY;
517
+ updateTransform(side);
518
+ }});
519
+
520
+ document.addEventListener("mouseup",function(){{
521
+ var s=panState[side];
522
+ s.drag=false;
523
+ container.classList.remove("dragging");
524
+ }});
525
+
526
+ container.addEventListener("wheel",function(e){{
527
+ e.preventDefault();
528
+ var s=panState[side];
529
+ var delta=e.deltaY>0?0.9:1.1;
530
+ var newScale=Math.max(0.1,Math.min(10,s.scale*delta));
531
+ var rect=container.getBoundingClientRect();
532
+ var mx=e.clientX-rect.left;
533
+ var my=e.clientY-rect.top;
534
+ s.panX=mx-(mx-s.panX)*(newScale/s.scale);
535
+ s.panY=my-(my-s.panY)*(newScale/s.scale);
536
+ s.scale=newScale;
537
+ updateTransform(side);
538
+ }},{{passive:false}});
539
+ }}
540
+
541
+ attachPanZoom(leftContainer,"left");
542
+ attachPanZoom(rightContainer,"right");
543
+ resetBtn.onclick=resetView;
544
+
545
+ function toggleFullscreen(){{
546
+ if(!document.fullscreenElement){{
547
+ document.documentElement.requestFullscreen();
548
+ }}else{{
549
+ document.exitFullscreen();
550
+ }}
551
+ }}
552
+ fullscreenBtn.onclick=toggleFullscreen;
553
+
554
+ document.addEventListener("fullscreenchange",function(){{
555
+ if(document.fullscreenElement){{
556
+ document.body.classList.add("fullscreen");
557
+ setTimeout(function(){{fitToView("left");fitToView("right");}},50);
558
+ }}else{{
559
+ document.body.classList.remove("fullscreen");
560
+ resetView();
561
+ }}
562
+ }});
563
+
564
+ function show(i){{
565
+ i=Math.max(0,Math.min({total_n - 1},i|0));
566
+ idx=i;
567
+ var li=Math.min(i,framesLeft.length-1);
568
+ var ri=Math.min(i,framesRight.length-1);
569
+ leftImg.src=framesLeft[li];
570
+ rightImg.src=framesRight[ri];
571
+ slider.value=i;
572
+ label.textContent="{counter_label_escaped}";
573
+ }}
574
+
575
+ function step(d){{
576
+ var i=idx+d;
577
+ if(i>= {total_n}) i=loopEnabled?0:{total_n - 1};
578
+ if(i<0) i=loopEnabled?{total_n - 1}:0;
579
+ show(i);
580
+ }}
581
+
582
+ function play(){{
583
+ if(playing)return;
584
+ playing=true;
585
+ toggle.textContent="⏸️";
586
+ timer=setInterval(function(){{step(1);}},1000/Math.max(1,fps));
587
+ }}
588
+
589
+ function pause(){{
590
+ if(!playing)return;
591
+ playing=false;
592
+ toggle.textContent="⏯️";
593
+ clearInterval(timer);
594
+ timer=null;
595
+ }}
596
+
597
+ slider.oninput=function(e){{show(parseInt(e.target.value));}};
598
+ document.getElementById("back").onclick=function(){{pause();step(-1);}};
599
+ document.getElementById("fwd").onclick=function(){{pause();step(1);}};
600
+ toggle.onclick=function(){{if(playing)pause();else play();}};
601
+ fpsSlider.oninput=function(){{
602
+ fps=parseInt(fpsSlider.value);
603
+ fpsLabelEl.textContent=fps;
604
+ if(playing){{pause();play();}}
605
+ }};
606
+ document.onkeydown=function(e){{
607
+ if(e.key===" "){{e.preventDefault();if(playing)pause();else play();}}
608
+ else if(e.key==="ArrowRight")step(1);
609
+ else if(e.key==="ArrowLeft")step(-1);
610
+ else if(e.key==="r"||e.key==="R")resetView();
611
+ }};
612
+ show(0);
613
+ </script>
614
+ </body>
615
+ </html>"""
616
+
617
+ srcdoc_escaped = html_module.escape(inner_html)
618
+ return f'<iframe srcdoc="{srcdoc_escaped}" style="width:100%;height:{iframe_height}px;border:none;border-radius:12px;"></iframe>'