dquarel commited on
Commit
1d06b77
·
1 Parent(s): 163c894

checkpoint viewer ready?

Browse files
Files changed (4) hide show
  1. README.md +37 -5
  2. app.py +230 -0
  3. requirements.txt +4 -0
  4. vidshow.py +267 -0
README.md CHANGED
@@ -1,12 +1,44 @@
1
  ---
2
- title: Jaxgmg Hf
3
- emoji: 🐢
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.2.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: JaxGMG Action Probs Viewer
3
+ emoji: 🎮
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.2.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # JaxGMG Action Probabilities Viewer
13
+
14
+ Visualize action probability heatmaps from trained RL agents from the [JaxGMG collection](https://huggingface.co/collections/davidquarel/jaxgmg).
15
+
16
+ ## Features
17
+ - Browse 14 model repositories from the JaxGMG collection
18
+ - Searchable checkpoint dropdown
19
+ - Interactive video player with:
20
+ - Play/pause, frame-by-frame navigation
21
+ - Click-drag to pan, scroll to zoom
22
+ - Fit to view / actual size buttons
23
+ - Fullscreen mode
24
+ - Adjustable FPS
25
+ - Keyboard shortcuts (Space, Arrows, F, 1, Esc)
26
+
27
+ ## Local Development
28
+
29
+ ```bash
30
+ # Clone the model repo (optional, for faster local testing)
31
+ git clone https://huggingface.co/davidquarel/jaxgmg_3phase_seed ../jaxgmg_3phase_seed
32
+ cd ../jaxgmg_3phase_seed
33
+ git lfs pull --include="*/action_probs.tar.gz"
34
+
35
+ # Run with local data
36
+ JAXGMG_USE_LOCAL=1 python app.py
37
+
38
+ # Test without Gradio (generates HTML file)
39
+ JAXGMG_USE_LOCAL=1 JAXGMG_TEST=1 python app.py
40
+ ```
41
+
42
+ ## Environment Variables
43
+ - `JAXGMG_USE_LOCAL=1` - Use local cloned repos instead of downloading from HuggingFace
44
+ - `JAXGMG_TEST=1` - Generate test HTML file instead of launching Gradio server
app.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tarfile
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+ from huggingface_hub import HfApi, hf_hub_download
8
+ from vidshow import vidshow_from_png_bytes
9
+
10
+ JAXGMG_REPOS = [
11
+ "davidquarel/jaxgmg_3phase_seed",
12
+ "davidquarel/jaxgmg_3phase_bf16",
13
+ "davidquarel/jaxgmg_al_sweep",
14
+ "davidquarel/jaxgmg_al_sweep2",
15
+ "davidquarel/jaxgmg_al_g_sweep",
16
+ "davidquarel/jaxgmg_smoke",
17
+ "davidquarel/jaxgmg_al_1e0",
18
+ "davidquarel/jaxgmg_checkpoints",
19
+ "davidquarel/jaxgmg_open_alpha0_gamma_sweep",
20
+ "davidquarel/jaxgmg_ckpt_df",
21
+ "davidquarel/jaxgmg_ckpt_zip",
22
+ "davidquarel/jaxgmg_ckpt_pt_OLD",
23
+ "davidquarel/jaxgmg_test",
24
+ "davidquarel/matt_jaxgmg_ckpt",
25
+ ]
26
+
27
+ DEFAULT_REPO = "davidquarel/jaxgmg_3phase_seed"
28
+ LOCAL_BASE_PATH = Path(__file__).parent.parent
29
+ USE_LOCAL = os.environ.get("JAXGMG_USE_LOCAL", "").lower() in ("1", "true", "yes")
30
+
31
+ _hf_api: HfApi | None = None
32
+
33
+
34
+ def get_hf_api() -> HfApi:
35
+ global _hf_api
36
+ if _hf_api is None:
37
+ _hf_api = HfApi()
38
+ return _hf_api
39
+
40
+
41
+ def get_local_path(repo_id: str) -> Path:
42
+ return LOCAL_BASE_PATH / repo_id.split("/")[-1]
43
+
44
+
45
+ @lru_cache(maxsize=32)
46
+ def get_checkpoint_list(repo_id: str) -> tuple[str, ...]:
47
+ local_path = get_local_path(repo_id)
48
+ if USE_LOCAL and local_path.exists():
49
+ checkpoints = sorted(
50
+ d.name for d in local_path.iterdir()
51
+ if d.is_dir() and not d.name.startswith(".")
52
+ )
53
+ else:
54
+ files = get_hf_api().list_repo_files(repo_id, repo_type="model")
55
+ checkpoints = sorted(set(
56
+ f.split("/")[0] for f in files
57
+ if "/" in f and f.endswith("action_probs.tar.gz")
58
+ ))
59
+ return tuple(checkpoints)
60
+
61
+
62
+ def update_checkpoints(repo_id: str):
63
+ checkpoints = list(get_checkpoint_list(repo_id))
64
+ return gr.Dropdown(
65
+ choices=checkpoints,
66
+ value=checkpoints[0] if checkpoints else None,
67
+ )
68
+
69
+
70
+ def load_action_probs(repo_id: str, checkpoint: str, progress=gr.Progress()) -> str:
71
+ if not checkpoint:
72
+ return "<div style='padding:40px;text-align:center;color:#f88;'>Please select a checkpoint</div>"
73
+
74
+ progress(0.05, desc="Locating file...")
75
+
76
+ local_path = get_local_path(repo_id)
77
+ local_file = local_path / checkpoint / "action_probs.tar.gz"
78
+
79
+ if USE_LOCAL and local_file.exists() and local_file.stat().st_size > 1000:
80
+ tar_path = str(local_file)
81
+ else:
82
+ progress(0.1, desc="Downloading from HuggingFace...")
83
+ tar_path = hf_hub_download(
84
+ repo_id=repo_id,
85
+ filename=f"{checkpoint}/action_probs.tar.gz",
86
+ repo_type="model",
87
+ )
88
+
89
+ progress(0.2, desc="Reading archive...")
90
+
91
+ png_bytes_list: list[bytes] = []
92
+ with tarfile.open(tar_path, "r:gz") as tar:
93
+ members = sorted(
94
+ (m for m in tar.getmembers() if m.name.endswith(".png")),
95
+ key=lambda m: m.name
96
+ )
97
+ total = len(members)
98
+ last_update = 0
99
+ for i, member in enumerate(members):
100
+ f = tar.extractfile(member)
101
+ if f is not None:
102
+ png_bytes_list.append(f.read())
103
+ if i - last_update >= 100:
104
+ progress(0.2 + 0.6 * (i / total), desc=f"Reading ({i}/{total})...")
105
+ last_update = i
106
+
107
+ progress(0.85, desc="Building player...")
108
+
109
+ title = f"{repo_id.split('/')[-1]} / {checkpoint}"
110
+ html = vidshow_from_png_bytes(png_bytes_list, fps=12, loop=True, title=title)
111
+
112
+ progress(1.0, desc="Done!")
113
+ return html
114
+
115
+
116
+ def create_demo():
117
+ initial_checkpoints = list(get_checkpoint_list(DEFAULT_REPO))
118
+
119
+ with gr.Blocks() as demo:
120
+ gr.Markdown(
121
+ """
122
+ # 🎮 JaxGMG Action Probabilities Viewer
123
+
124
+ Visualize action probability heatmaps from trained RL agents.
125
+ Use the slider to scrub through frames, or press play.
126
+ """
127
+ )
128
+
129
+ with gr.Row():
130
+ with gr.Column(scale=1):
131
+ repo_dropdown = gr.Dropdown(
132
+ choices=JAXGMG_REPOS,
133
+ value=DEFAULT_REPO,
134
+ label="Repository",
135
+ filterable=True,
136
+ info="Choose from the JaxGMG collection"
137
+ )
138
+ checkpoint_dropdown = gr.Dropdown(
139
+ choices=initial_checkpoints,
140
+ value=initial_checkpoints[0] if initial_checkpoints else None,
141
+ label="Checkpoint",
142
+ filterable=True,
143
+ info="Type to search"
144
+ )
145
+ load_btn = gr.Button("Load", variant="primary", size="lg")
146
+
147
+ gr.Markdown(
148
+ f"**Source:** {'Local' if USE_LOCAL else 'HuggingFace'} · "
149
+ f"[Collection](https://huggingface.co/collections/davidquarel/jaxgmg)"
150
+ )
151
+
152
+ output_html = gr.HTML(
153
+ value="<div style='padding:40px;text-align:center;color:#888;'>Select a checkpoint and click Load</div>"
154
+ )
155
+
156
+ repo_dropdown.change(
157
+ fn=update_checkpoints,
158
+ inputs=[repo_dropdown],
159
+ outputs=[checkpoint_dropdown],
160
+ )
161
+
162
+ load_btn.click(
163
+ fn=load_action_probs,
164
+ inputs=[repo_dropdown, checkpoint_dropdown],
165
+ outputs=[output_html],
166
+ )
167
+
168
+ return demo
169
+
170
+
171
+ def test_load():
172
+ """Test loading without Gradio - run with JAXGMG_TEST=1"""
173
+ print("Testing load...")
174
+ checkpoints = get_checkpoint_list(DEFAULT_REPO)
175
+ print(f"Found {len(checkpoints)} checkpoints")
176
+ checkpoint = checkpoints[0]
177
+ print(f"Loading {checkpoint}...")
178
+
179
+ local_path = get_local_path(DEFAULT_REPO)
180
+ local_file = local_path / checkpoint / "action_probs.tar.gz"
181
+
182
+ if USE_LOCAL and local_file.exists() and local_file.stat().st_size > 1000:
183
+ tar_path = str(local_file)
184
+ print(f"Using local: {tar_path}")
185
+ else:
186
+ print("Downloading from HuggingFace...")
187
+ tar_path = hf_hub_download(
188
+ repo_id=DEFAULT_REPO,
189
+ filename=f"{checkpoint}/action_probs.tar.gz",
190
+ repo_type="model",
191
+ )
192
+
193
+ print("Reading archive...")
194
+ png_bytes_list: list[bytes] = []
195
+ with tarfile.open(tar_path, "r:gz") as tar:
196
+ members = sorted(
197
+ (m for m in tar.getmembers() if m.name.endswith(".png")),
198
+ key=lambda m: m.name
199
+ )
200
+ total = len(members)
201
+ print(f"Found {total} images")
202
+ for i, member in enumerate(members):
203
+ f = tar.extractfile(member)
204
+ if f is not None:
205
+ png_bytes_list.append(f.read())
206
+ if (i + 1) % 100 == 0:
207
+ print(f" Read {i + 1}/{total}...")
208
+
209
+ print(f"Loaded {len(png_bytes_list)} frames")
210
+
211
+ print("Creating player...")
212
+ title = f"{DEFAULT_REPO.split('/')[-1]} / {checkpoint}"
213
+ html = vidshow_from_png_bytes(png_bytes_list, fps=12, title=title)
214
+
215
+ # Save to local tmp directory
216
+ tmp_dir = Path(__file__).parent / "tmp"
217
+ tmp_dir.mkdir(exist_ok=True)
218
+ html_path = tmp_dir / "test_vidshow.html"
219
+ with open(html_path, "w") as f:
220
+ f.write(f"<!DOCTYPE html><html><body style='margin:0;background:#0f0f1a;'>{html}</body></html>")
221
+ print(f"Saved to {html_path}")
222
+ print("Open in browser to test!")
223
+
224
+
225
+ if __name__ == "__main__":
226
+ if os.environ.get("JAXGMG_TEST", "").lower() in ("1", "true", "yes"):
227
+ test_load()
228
+ else:
229
+ demo = create_demo()
230
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ huggingface_hub>=0.20.0
3
+ numpy>=1.24.0
4
+ Pillow>=10.0.0
vidshow.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import html as html_module
3
+ import json
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+
7
+ def vidshow_from_png_bytes(
8
+ png_bytes_list: list[bytes],
9
+ *,
10
+ fps: int = 12,
11
+ loop: bool = True,
12
+ title: str | None = None,
13
+ ) -> str:
14
+ """Optimized vidshow that takes raw PNG bytes directly. Returns iframe with embedded player."""
15
+
16
+ def encode_one(png_bytes: bytes) -> str:
17
+ return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
18
+
19
+ with ThreadPoolExecutor(max_workers=8) as pool:
20
+ urls = list(pool.map(encode_one, png_bytes_list))
21
+
22
+ N = len(urls)
23
+ frames_json = json.dumps(urls)
24
+ title_escaped = html_module.escape(title) if title else "Video"
25
+ loop_js = "true" if loop else "false"
26
+
27
+ # Build standalone HTML document for the iframe
28
+ inner_html = f"""<!DOCTYPE html>
29
+ <html>
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <style>
33
+ *{{box-sizing:border-box;}}
34
+ html,body{{margin:0;padding:0;height:100%;}}
35
+ body{{
36
+ padding:12px;
37
+ background:#1a1a2e;
38
+ font-family:sans-serif;
39
+ display:flex;
40
+ flex-direction:column;
41
+ height:100%;
42
+ }}
43
+ .title{{font-weight:600;color:#e0e0e0;margin-bottom:8px;font-size:1.1em;text-align:center;flex-shrink:0;}}
44
+ .img-container{{
45
+ flex:1;
46
+ min-height:0;
47
+ overflow:hidden;
48
+ border-radius:8px;
49
+ box-shadow:0 4px 20px rgba(0,0,0,.4);
50
+ cursor:grab;
51
+ background:#0f0f1a;
52
+ position:relative;
53
+ }}
54
+ .img-container.dragging{{cursor:grabbing;}}
55
+ img{{
56
+ position:absolute;
57
+ top:0;left:0;
58
+ image-rendering:pixelated;
59
+ user-select:none;
60
+ -webkit-user-drag:none;
61
+ transform-origin:0 0;
62
+ }}
63
+ .controls{{display:flex;gap:8px;align-items:center;margin-top:12px;flex-shrink:0;}}
64
+ button{{padding:6px 12px;border-radius:6px;border:none;background:#4a4a6a;color:#fff;cursor:pointer;font-size:1em;}}
65
+ button:hover{{background:#5a5a7a;}}
66
+ button.primary{{background:#6366f1;}}
67
+ button.primary:hover{{background:#7376f2;}}
68
+ input[type=range]{{flex:1;accent-color:#6366f1;}}
69
+ .label{{min-width:7ch;text-align:right;font-variant-numeric:tabular-nums;color:#e0e0e0;}}
70
+ .fps-row{{display:flex;align-items:center;gap:8px;margin-top:10px;flex-shrink:0;}}
71
+ .fps-label{{font-size:0.9em;color:#a0a0a0;}}
72
+ .fps-value{{min-width:3ch;text-align:right;color:#e0e0e0;}}
73
+ .hint{{font-size:0.8em;color:#666;margin-top:8px;text-align:center;flex-shrink:0;}}
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <div class="title">{title_escaped}</div>
78
+ <div class="img-container" id="imgContainer">
79
+ <img id="img" src="{urls[0]}">
80
+ </div>
81
+ <div class="controls">
82
+ <button id="back">◀</button>
83
+ <button id="toggle" class="primary">⏯️</button>
84
+ <button id="fwd">▶</button>
85
+ <button id="fit" title="Fit to view">⊡</button>
86
+ <button id="actual" title="Actual size">1:1</button>
87
+ <button id="fullscreen" title="Fullscreen (Esc to exit)">⛶</button>
88
+ <input type="range" id="slider" min="0" max="{N-1}" value="0" step="1">
89
+ <span id="label" class="label">1/{N}</span>
90
+ </div>
91
+ <div class="fps-row">
92
+ <span class="fps-label">FPS:</span>
93
+ <input type="range" id="fps" min="1" max="60" value="{fps}" step="1">
94
+ <span id="fpsLabel" class="fps-value">{fps}</span>
95
+ </div>
96
+ <div class="hint">Drag to pan • Scroll to zoom • F=fit • 1=actual size • Esc=exit fullscreen</div>
97
+ <script>
98
+ var frames={frames_json};
99
+ var loopEnabled={loop_js};
100
+ var playing=false,timer=null,fps={fps},idx=0;
101
+
102
+ var img=document.getElementById("img");
103
+ var imgContainer=document.getElementById("imgContainer");
104
+ var slider=document.getElementById("slider");
105
+ var label=document.getElementById("label");
106
+ var toggle=document.getElementById("toggle");
107
+ var fpsSlider=document.getElementById("fps");
108
+ var fpsLabelEl=document.getElementById("fpsLabel");
109
+ var fitBtn=document.getElementById("fit");
110
+ var actualBtn=document.getElementById("actual");
111
+ var fullscreenBtn=document.getElementById("fullscreen");
112
+
113
+ // Pan/zoom state
114
+ var isDragging=false;
115
+ var startX=0,startY=0;
116
+ var panX=0,panY=0;
117
+ var scale=1;
118
+ var imgW=0,imgH=0;
119
+
120
+ function updateTransform(){{
121
+ img.style.transform="translate("+panX+"px,"+panY+"px) scale("+scale+")";
122
+ }}
123
+
124
+ function fitToView(){{
125
+ var cw=imgContainer.clientWidth;
126
+ var ch=imgContainer.clientHeight;
127
+ if(imgW===0||imgH===0)return;
128
+ var scaleX=cw/imgW;
129
+ var scaleY=ch/imgH;
130
+ scale=Math.min(scaleX,scaleY,1);
131
+ panX=(cw-imgW*scale)/2;
132
+ panY=(ch-imgH*scale)/2;
133
+ updateTransform();
134
+ }}
135
+
136
+ function actualSize(){{
137
+ var cw=imgContainer.clientWidth;
138
+ var ch=imgContainer.clientHeight;
139
+ scale=1;
140
+ panX=(cw-imgW)/2;
141
+ panY=(ch-imgH)/2;
142
+ updateTransform();
143
+ }}
144
+
145
+ var initialLoad=true;
146
+ img.onload=function(){{
147
+ imgW=img.naturalWidth;
148
+ imgH=img.naturalHeight;
149
+ if(initialLoad){{
150
+ fitToView();
151
+ initialLoad=false;
152
+ }}
153
+ }};
154
+
155
+ window.addEventListener("resize",fitToView);
156
+
157
+ // Mouse drag for panning
158
+ imgContainer.addEventListener("mousedown",function(e){{
159
+ if(e.button!==0)return;
160
+ isDragging=true;
161
+ startX=e.clientX-panX;
162
+ startY=e.clientY-panY;
163
+ imgContainer.classList.add("dragging");
164
+ e.preventDefault();
165
+ }});
166
+
167
+ document.addEventListener("mousemove",function(e){{
168
+ if(!isDragging)return;
169
+ panX=e.clientX-startX;
170
+ panY=e.clientY-startY;
171
+ updateTransform();
172
+ }});
173
+
174
+ document.addEventListener("mouseup",function(){{
175
+ isDragging=false;
176
+ imgContainer.classList.remove("dragging");
177
+ }});
178
+
179
+ // Scroll to zoom
180
+ imgContainer.addEventListener("wheel",function(e){{
181
+ e.preventDefault();
182
+ var delta=e.deltaY>0?0.9:1.1;
183
+ var newScale=Math.max(0.1,Math.min(10,scale*delta));
184
+
185
+ var rect=imgContainer.getBoundingClientRect();
186
+ var mx=e.clientX-rect.left;
187
+ var my=e.clientY-rect.top;
188
+
189
+ panX=mx-(mx-panX)*(newScale/scale);
190
+ panY=my-(my-panY)*(newScale/scale);
191
+ scale=newScale;
192
+
193
+ updateTransform();
194
+ }},{{passive:false}});
195
+
196
+ fitBtn.onclick=fitToView;
197
+ actualBtn.onclick=actualSize;
198
+
199
+ function toggleFullscreen(){{
200
+ if(!document.fullscreenElement){{
201
+ document.documentElement.requestFullscreen().then(function(){{
202
+ setTimeout(fitToView,100);
203
+ }});
204
+ }}else{{
205
+ document.exitFullscreen();
206
+ }}
207
+ }}
208
+ fullscreenBtn.onclick=toggleFullscreen;
209
+ document.addEventListener("fullscreenchange",function(){{
210
+ setTimeout(fitToView,100);
211
+ }});
212
+
213
+ function show(i){{
214
+ i=Math.max(0,Math.min(frames.length-1,i|0));
215
+ idx=i;
216
+ img.src=frames[i];
217
+ slider.value=i;
218
+ label.textContent=(i+1)+"/"+frames.length;
219
+ }}
220
+
221
+ function step(d){{
222
+ var i=idx+d;
223
+ if(i>=frames.length)i=loopEnabled?0:frames.length-1;
224
+ if(i<0)i=loopEnabled?frames.length-1:0;
225
+ show(i);
226
+ }}
227
+
228
+ function play(){{
229
+ if(playing)return;
230
+ playing=true;
231
+ toggle.textContent="⏸️";
232
+ timer=setInterval(function(){{step(1);}},1000/Math.max(1,fps));
233
+ }}
234
+
235
+ function pause(){{
236
+ if(!playing)return;
237
+ playing=false;
238
+ toggle.textContent="⏯️";
239
+ clearInterval(timer);
240
+ timer=null;
241
+ }}
242
+
243
+ slider.oninput=function(e){{show(parseInt(e.target.value));}};
244
+ document.getElementById("back").onclick=function(){{pause();step(-1);}};
245
+ document.getElementById("fwd").onclick=function(){{pause();step(1);}};
246
+ toggle.onclick=function(){{if(playing)pause();else play();}};
247
+ fpsSlider.oninput=function(){{
248
+ fps=parseInt(fpsSlider.value);
249
+ fpsLabelEl.textContent=fps;
250
+ if(playing){{pause();play();}}
251
+ }};
252
+ document.onkeydown=function(e){{
253
+ if(e.key===" "){{e.preventDefault();if(playing)pause();else play();}}
254
+ else if(e.key==="ArrowRight")step(1);
255
+ else if(e.key==="ArrowLeft")step(-1);
256
+ else if(e.key==="f"||e.key==="F")fitToView();
257
+ else if(e.key==="1")actualSize();
258
+ }};
259
+ </script>
260
+ </body>
261
+ </html>"""
262
+
263
+ # Escape the inner HTML for use in srcdoc attribute
264
+ srcdoc_escaped = html_module.escape(inner_html)
265
+
266
+ # Wrap in iframe - srcdoc allows scripts to run
267
+ return f'<iframe srcdoc="{srcdoc_escaped}" style="width:100%;height:90vh;border:none;border-radius:12px;"></iframe>'