BoxOfColors commited on
Commit
fcc4220
Β·
1 Parent(s): 6390bb5

Fix waveform rendering: use iframe srcdoc to ensure JS executes

Browse files

Gradio's gr.HTML updates via innerHTML which strips/ignores <script>
tags. Moving canvas + Web Audio API logic into an iframe srcdoc ensures
scripts always execute on every update. The iframe accesses window.parent
directly (same-origin sandbox) to trigger the Gradio regen hidden input.
Playhead sync polls window.parent for the video element every 50ms.

Files changed (1) hide show
  1. app.py +187 -204
app.py CHANGED
@@ -1108,274 +1108,257 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
1108
  "rgba(255,220,80,0.35)", "rgba(80,220,220,0.35)",
1109
  "rgba(255,100,100,0.35)", "rgba(180,255,180,0.35)"]
1110
 
1111
- return f"""
1112
- <div id="wf_container_{slot_id}"
1113
- style="background:#1a1a1a;border-radius:8px;padding:10px;margin-top:6px;position:relative;">
1114
- <div style="position:relative;width:100%;height:80px;">
1115
- <canvas id="wf_canvas_{slot_id}"
1116
- style="width:100%;height:80px;display:block;border-radius:4px;"></canvas>
1117
- <canvas id="wf_playhead_{slot_id}"
1118
- style="position:absolute;top:0;left:0;width:100%;height:80px;pointer-events:none;"></canvas>
1119
- </div>
1120
- <div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
1121
- <span style="color:#888;font-size:11px;">Click a segment to regenerate &nbsp;|&nbsp; Playhead syncs to video</span>
1122
- <a href="{data_uri}" download="audio_{slot_id}.wav"
1123
- style="margin-left:auto;background:#333;color:#eee;border:1px solid #555;
1124
- border-radius:4px;padding:3px 10px;font-size:12px;text-decoration:none;">
1125
- &#8595; Download</a>
1126
- </div>
1127
- <div id="wf_seglabel_{slot_id}"
1128
- style="color:#aaa;font-size:11px;margin-top:4px;min-height:16px;"></div>
1129
-
1130
- <!-- Popup that appears on segment click -->
1131
- <div id="wf_popup_{slot_id}"
1132
- style="display:none;position:fixed;z-index:9999;
1133
- background:#2a2a2a;border:1px solid #555;border-radius:6px;
1134
- padding:8px 12px;box-shadow:0 4px 16px rgba(0,0,0,0.5);">
1135
- <div id="wf_popup_label_{slot_id}"
1136
- style="color:#ccc;font-size:11px;margin-bottom:6px;white-space:nowrap;"></div>
1137
- <button id="wf_regen_btn_{slot_id}"
1138
- style="background:#1d6fa5;color:#fff;border:none;border-radius:4px;
1139
- padding:5px 14px;font-size:12px;cursor:pointer;width:100%;">
1140
- &#10227; Regenerate
1141
- </button>
1142
- </div>
 
 
 
 
 
 
1143
  </div>
1144
  <script>
1145
  (function() {{
1146
- // Clean up any previous listeners for this slot
1147
- if (window["_wf_video_unlisten_{slot_id}"]) {{
1148
- try {{ window["_wf_video_unlisten_{slot_id}"](); }} catch(e) {{}}
1149
- window["_wf_video_unlisten_{slot_id}"] = null;
1150
- }}
1151
-
1152
- console.log('[waveform {slot_id}] script executing');
1153
- const segments = {segs_json};
1154
  const segColors = {json.dumps(seg_colors)};
1155
  let audioDuration = 0;
1156
- let _pendingSegIdx_{slot_id} = null;
1157
-
1158
- // ── Popup helpers ──────────────────────────────────────────────────
1159
- function fireRegen(idx) {{
1160
- document.getElementById('wf_popup_{slot_id}').style.display = 'none';
1161
- const lbl = document.getElementById('wf_seglabel_{slot_id}');
1162
- if (lbl) lbl.textContent = 'Regenerating Seg ' + (idx+1) +
1163
- ' (' + segments[idx][0].toFixed(2) + 's \u2013 ' + segments[idx][1].toFixed(2) + 's)\u2026';
1164
- const el = document.getElementById('{hidden_input_id}');
1165
- if (el) {{
1166
- const input = el.querySelector('input, textarea');
1167
- if (input) {{
1168
- const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
1169
- || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
1170
- if (desc && desc.set) desc.set.call(input, '{slot_id}|' + idx);
1171
- else input.value = '{slot_id}|' + idx;
1172
- input.dispatchEvent(new Event('input', {{bubbles:true}}));
1173
- }}
1174
- }}
1175
- }}
1176
 
1177
  function showPopup(idx, mx, my) {{
1178
- _pendingSegIdx_{slot_id} = idx;
1179
- const popup = document.getElementById('wf_popup_{slot_id}');
1180
- const plbl = document.getElementById('wf_popup_label_{slot_id}');
1181
- if (plbl) plbl.textContent = 'Seg '+(idx+1)+' ('+segments[idx][0].toFixed(2)+'s \u2013 '+segments[idx][1].toFixed(2)+'s)';
1182
  popup.style.display = 'block';
1183
- popup.style.left = (mx+10)+'px'; popup.style.top = (my+10)+'px';
 
1184
  requestAnimationFrame(function() {{
1185
  const r=popup.getBoundingClientRect(), vw=window.innerWidth, vh=window.innerHeight;
1186
- if (r.right>vw-8) popup.style.left=(vw-r.width-8)+'px';
1187
- if (r.bottom>vh-8) popup.style.top=(vh-r.height-8)+'px';
1188
  }});
1189
  }}
1190
-
1191
- function hidePopup() {{
1192
- document.getElementById('wf_popup_{slot_id}').style.display='none';
1193
- _pendingSegIdx_{slot_id} = null;
1194
- }}
1195
-
1196
- (function tryWireBtn() {{
1197
- const btn = document.getElementById('wf_regen_btn_{slot_id}');
1198
- if (btn) {{ btn.onclick = function(e) {{ e.stopPropagation(); if (_pendingSegIdx_{slot_id}!==null) fireRegen(_pendingSegIdx_{slot_id}); }}; }}
1199
- else setTimeout(tryWireBtn, 100);
1200
- }})();
1201
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1202
  document.addEventListener('click', function(e) {{
1203
- const p=document.getElementById('wf_popup_{slot_id}');
1204
- if (p && p.style.display!=='none' && !p.contains(e.target)) hidePopup();
1205
  }}, true);
1206
 
1207
- // ── Canvas waveform drawing ────────────────────────────────────────
 
 
 
 
1208
  function drawWaveform(channelData, duration) {{
1209
  audioDuration = duration;
1210
- const canvas = document.getElementById('wf_canvas_{slot_id}');
1211
- if (!canvas) {{ console.error('[waveform {slot_id}] drawWaveform: canvas element missing'); return; }}
1212
  const dpr = window.devicePixelRatio || 1;
1213
- const rect = canvas.getBoundingClientRect();
1214
- const W = rect.width
1215
- || (canvas.parentElement ? canvas.parentElement.getBoundingClientRect().width : 0)
1216
- || 600;
1217
  const H = 80;
1218
- console.log('[waveform {slot_id}] drawWaveform W=' + W + ' H=' + H + ' dpr=' + dpr + ' rect=' + JSON.stringify(rect));
1219
- canvas.width = W * dpr;
1220
- canvas.height = H * dpr;
1221
- const ctx = canvas.getContext('2d');
1222
- if (!ctx) {{ console.error('[waveform {slot_id}] drawWaveform: could not get 2d context'); return; }}
1223
  ctx.scale(dpr, dpr);
1224
 
1225
- // Background
1226
  ctx.fillStyle = '#1e1e2e';
1227
  ctx.fillRect(0, 0, W, H);
1228
 
1229
- // Segment region overlays
1230
  segments.forEach(function(seg, idx) {{
1231
  const x1 = (seg[0] / duration) * W;
1232
  const x2 = (seg[1] / duration) * W;
1233
  ctx.fillStyle = segColors[idx % segColors.length];
1234
  ctx.fillRect(x1, 0, x2-x1, H);
1235
- // Segment label
1236
  ctx.fillStyle = 'rgba(255,255,255,0.6)';
1237
  ctx.font = '10px sans-serif';
1238
  ctx.fillText('Seg '+(idx+1), x1+3, 12);
1239
  }});
1240
 
1241
- // Waveform bars
1242
  const samples = channelData.length;
1243
- const barW = 2, gap = 1, step = barW + gap;
1244
  const numBars = Math.floor(W / step);
1245
  const blockSz = Math.floor(samples / numBars);
1246
  ctx.fillStyle = '#4a9eff';
1247
- for (let i = 0; i < numBars; i++) {{
1248
- let max = 0;
1249
- const start = i * blockSz;
1250
- for (let j = 0; j < blockSz; j++) {{
1251
- const v = Math.abs(channelData[start + j] || 0);
1252
- if (v > max) max = v;
1253
  }}
1254
- const barH = Math.max(1, max * H);
1255
- ctx.fillRect(i * step, (H - barH) / 2, barW, barH);
1256
  }}
1257
 
1258
- // Segment boundary lines
1259
  segments.forEach(function(seg) {{
1260
- [seg[0], seg[1]].forEach(function(t) {{
1261
- const x = (t / duration) * W;
1262
- ctx.strokeStyle = 'rgba(255,255,255,0.4)';
1263
- ctx.lineWidth = 1;
1264
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
1265
  }});
1266
  }});
1267
 
1268
- // Click handler for segment selection
1269
- canvas.onclick = function(e) {{
1270
- const rect = canvas.getBoundingClientRect();
1271
- const xRel = (e.clientX - rect.left) / rect.width;
1272
- const tClick = xRel * duration;
1273
- let hit = -1;
1274
- segments.forEach(function(seg, idx) {{ if (tClick >= seg[0] && tClick <= seg[1]) hit = idx; }});
1275
- if (hit >= 0) showPopup(hit, e.clientX, e.clientY);
1276
  else hidePopup();
1277
  }};
1278
  }}
1279
 
1280
- // ── Playhead drawing ──────────────────────────────────────────────
1281
  function drawPlayhead(progress) {{
1282
- const canvas = document.getElementById('wf_playhead_{slot_id}');
1283
- if (!canvas) return;
1284
  const dpr = window.devicePixelRatio || 1;
1285
- const W = canvas.getBoundingClientRect().width
1286
- || (canvas.parentElement ? canvas.parentElement.getBoundingClientRect().width : 0)
1287
- || 600;
1288
  const H = 80;
1289
- if (canvas.width !== W*dpr) {{ canvas.width=W*dpr; canvas.height=H*dpr; }}
1290
- const ctx = canvas.getContext('2d');
1291
- ctx.clearRect(0, 0, W*dpr, H*dpr);
1292
- ctx.scale(dpr, dpr);
1293
- const x = progress * W;
1294
- ctx.strokeStyle = '#fff';
1295
- ctx.lineWidth = 2;
1296
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
1297
- // Reset transform for next call
1298
- ctx.setTransform(1,0,0,1,0,0);
1299
- }}
1300
-
1301
- // ── Video sync ────────────────────────────────────────────────────
1302
- function findSlotVideo() {{
1303
- let node = document.getElementById('wf_container_{slot_id}');
1304
- while (node && node !== document.body) {{
1305
- const v = node.querySelector('video');
1306
- if (v) return v;
1307
- node = node.parentElement;
1308
- }}
1309
- return null;
1310
  }}
1311
 
1312
- function attachVideoSync() {{
1313
- (function tryAttach() {{
1314
- const video = findSlotVideo();
1315
- if (!video) {{ setTimeout(tryAttach, 300); return; }}
1316
- function onTimeUpdate() {{
1317
- if (!video.duration || !isFinite(video.duration)) return;
1318
- drawPlayhead(video.currentTime / video.duration);
1319
  }}
1320
- video.addEventListener('timeupdate', onTimeUpdate);
1321
- window["_wf_video_unlisten_{slot_id}"] = function() {{
1322
- video.removeEventListener('timeupdate', onTimeUpdate);
1323
- }};
1324
- }})();
1325
- }}
1326
 
1327
- // ── Decode audio & render ─────────────────────────────────────────
1328
- // Decode audio immediately (doesn't need canvas dimensions).
1329
- // Drawing is deferred until the canvas actually has a non-zero width.
1330
  const b64str = '{b64}';
1331
- console.log('[waveform {slot_id}] b64 length=' + b64str.length);
1332
  const bin = atob(b64str);
1333
  const buf = new Uint8Array(bin.length);
1334
- for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
1335
- console.log('[waveform {slot_id}] decoded buf size=' + buf.byteLength + ' bytes');
1336
-
1337
- // OfflineAudioContext for decoding β€” works without user gesture, no playback
1338
- const OfflineCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
1339
- const AudioCtx = window.AudioContext || window.webkitAudioContext;
1340
- if (!OfflineCtx && !AudioCtx) {{
1341
- console.warn('[waveform {slot_id}] No AudioContext available');
1342
- }} else {{
1343
- // Decode using a throwaway AudioContext (OfflineAudioContext.decodeAudioData
1344
- // has the same user-gesture restriction in some browsers, so use regular one)
1345
- const tmpCtx = new (AudioCtx || OfflineCtx)({{sampleRate: 44100}});
1346
- const doRender = function(audioBuffer) {{
1347
- const channelData = audioBuffer.getChannelData(0);
1348
- const duration = audioBuffer.duration;
1349
- try {{ tmpCtx.close(); }} catch(e) {{}}
1350
- console.log('[waveform {slot_id}] decoded OK, duration=' + duration + 's, samples=' + channelData.length);
1351
-
1352
- function tryDraw() {{
1353
- const canvas = document.getElementById('wf_canvas_{slot_id}');
1354
- if (!canvas) {{ console.log('[waveform {slot_id}] canvas not in DOM yet'); setTimeout(tryDraw, 100); return; }}
1355
- let W = canvas.getBoundingClientRect().width;
1356
- if (W <= 0 && canvas.parentElement) W = canvas.parentElement.getBoundingClientRect().width;
1357
- if (W <= 0 && canvas.parentElement && canvas.parentElement.parentElement)
1358
- W = canvas.parentElement.parentElement.getBoundingClientRect().width;
1359
- console.log('[waveform {slot_id}] tryDraw W=' + W);
1360
- if (W <= 0) {{ setTimeout(tryDraw, 150); return; }}
1361
- drawWaveform(channelData, duration);
1362
- attachVideoSync();
1363
- }}
1364
- tryDraw();
1365
- }};
1366
 
1367
- // decodeAudioData β€” use callback form only to avoid double-calling doRender
1368
- console.log('[waveform {slot_id}] calling decodeAudioData, buf byteLength=' + buf.buffer.byteLength);
 
 
 
 
1369
  try {{
1370
- tmpCtx.decodeAudioData(buf.buffer.slice(0), doRender, function(err) {{
1371
- console.error('[waveform {slot_id}] decodeAudioData error:', err);
1372
- }});
1373
- }} catch(e) {{
1374
- console.error('[waveform {slot_id}] decodeAudioData threw:', e);
1375
- }}
 
 
 
 
 
 
 
 
 
1376
  }}
1377
  }})();
1378
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1379
  """
1380
 
1381
 
 
1108
  "rgba(255,220,80,0.35)", "rgba(80,220,220,0.35)",
1109
  "rgba(255,100,100,0.35)", "rgba(180,255,180,0.35)"]
1110
 
1111
+ # NOTE: Gradio updates gr.HTML via innerHTML which does NOT execute <script> tags.
1112
+ # Solution: put the entire waveform (canvas + JS) inside an <iframe srcdoc="...">.
1113
+ # iframes always execute their scripts. The iframe posts messages to the parent for
1114
+ # segment-click events; the parent listens and fires the Gradio regen trigger.
1115
+ # For playhead sync, the iframe polls window.parent for a <video> element.
1116
+
1117
+ iframe_inner = f"""<!DOCTYPE html>
1118
+ <html>
1119
+ <head>
1120
+ <meta charset="utf-8">
1121
+ <style>
1122
+ * {{ margin:0; padding:0; box-sizing:border-box; }}
1123
+ body {{ background:#1a1a1a; overflow:hidden; }}
1124
+ #wrap {{ position:relative; width:100%; height:80px; }}
1125
+ canvas {{ display:block; }}
1126
+ #cv {{ position:absolute; top:0; left:0; width:100%; height:100%; }}
1127
+ #cvp {{ position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }}
1128
+ #popup {{
1129
+ display:none; position:fixed; z-index:9999;
1130
+ background:#2a2a2a; border:1px solid #555; border-radius:6px;
1131
+ padding:8px 12px; box-shadow:0 4px 16px rgba(0,0,0,.5);
1132
+ font-family:sans-serif;
1133
+ }}
1134
+ #popuplbl {{ color:#ccc; font-size:11px; margin-bottom:6px; white-space:nowrap; }}
1135
+ #regenbtn {{
1136
+ background:#1d6fa5; color:#fff; border:none; border-radius:4px;
1137
+ padding:5px 14px; font-size:12px; cursor:pointer; width:100%;
1138
+ }}
1139
+ </style>
1140
+ </head>
1141
+ <body>
1142
+ <div id="wrap">
1143
+ <canvas id="cv"></canvas>
1144
+ <canvas id="cvp"></canvas>
1145
+ </div>
1146
+ <div id="popup">
1147
+ <div id="popuplbl"></div>
1148
+ <button id="regenbtn">&#10227; Regenerate</button>
1149
  </div>
1150
  <script>
1151
  (function() {{
1152
+ const SLOT_ID = '{slot_id}';
1153
+ const segments = {segs_json};
 
 
 
 
 
 
1154
  const segColors = {json.dumps(seg_colors)};
1155
  let audioDuration = 0;
1156
+ let pendingIdx = null;
1157
+
1158
+ // ── Popup ──────────────────────────────────────────────────────────
1159
+ const popup = document.getElementById('popup');
1160
+ const popuplbl= document.getElementById('popuplbl');
1161
+ const regenbtn= document.getElementById('regenbtn');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1162
 
1163
  function showPopup(idx, mx, my) {{
1164
+ pendingIdx = idx;
1165
+ popuplbl.textContent = 'Seg '+(idx+1)+' ('+segments[idx][0].toFixed(2)+'s \u2013 '+segments[idx][1].toFixed(2)+'s)';
 
 
1166
  popup.style.display = 'block';
1167
+ popup.style.left = (mx+10)+'px';
1168
+ popup.style.top = (my+10)+'px';
1169
  requestAnimationFrame(function() {{
1170
  const r=popup.getBoundingClientRect(), vw=window.innerWidth, vh=window.innerHeight;
1171
+ if (r.right >vw-8) popup.style.left=(vw-r.width-8)+'px';
1172
+ if (r.bottom>vh-8) popup.style.top =(vh-r.height-8)+'px';
1173
  }});
1174
  }}
1175
+ function hidePopup() {{ popup.style.display='none'; pendingIdx=null; }}
1176
+
1177
+ regenbtn.onclick = function(e) {{
1178
+ e.stopPropagation();
1179
+ if (pendingIdx !== null) {{
1180
+ // Directly trigger Gradio hidden input in parent document (same-origin iframe)
1181
+ try {{
1182
+ const par = window.parent.document;
1183
+ const el = par.getElementById('{hidden_input_id}');
1184
+ if (el) {{
1185
+ const input = el.querySelector('input, textarea');
1186
+ if (input) {{
1187
+ const desc = Object.getOwnPropertyDescriptor(
1188
+ window.parent.HTMLInputElement.prototype, 'value')
1189
+ || Object.getOwnPropertyDescriptor(
1190
+ window.parent.HTMLTextAreaElement.prototype, 'value');
1191
+ if (desc && desc.set) desc.set.call(input, SLOT_ID+'|'+pendingIdx);
1192
+ else input.value = SLOT_ID+'|'+pendingIdx;
1193
+ input.dispatchEvent(new window.parent.Event('input', {{bubbles:true}}));
1194
+ }}
1195
+ }}
1196
+ // Update status label in parent
1197
+ const lbl = par.getElementById('wf_seglabel_{slot_id}');
1198
+ if (lbl && segments[pendingIdx])
1199
+ lbl.textContent = 'Regenerating Seg '+(pendingIdx+1)+
1200
+ ' ('+segments[pendingIdx][0].toFixed(2)+'s \u2013 '+
1201
+ segments[pendingIdx][1].toFixed(2)+'s)\u2026';
1202
+ }} catch(err) {{
1203
+ console.error('[wf iframe] regen trigger failed:', err);
1204
+ }}
1205
+ hidePopup();
1206
+ }}
1207
+ }};
1208
  document.addEventListener('click', function(e) {{
1209
+ if (popup.style.display!=='none' && !popup.contains(e.target)) hidePopup();
 
1210
  }}, true);
1211
 
1212
+ // ── Canvas waveform ────────────────────────────────────────────────
1213
+ const cv = document.getElementById('cv');
1214
+ const cvp = document.getElementById('cvp');
1215
+ const wrap= document.getElementById('wrap');
1216
+
1217
  function drawWaveform(channelData, duration) {{
1218
  audioDuration = duration;
 
 
1219
  const dpr = window.devicePixelRatio || 1;
1220
+ const W = wrap.getBoundingClientRect().width || window.innerWidth || 600;
 
 
 
1221
  const H = 80;
1222
+ console.log('[wf iframe {slot_id}] drawWaveform W='+W+' H='+H);
1223
+ cv.width = W * dpr; cv.height = H * dpr;
1224
+ const ctx = cv.getContext('2d');
 
 
1225
  ctx.scale(dpr, dpr);
1226
 
 
1227
  ctx.fillStyle = '#1e1e2e';
1228
  ctx.fillRect(0, 0, W, H);
1229
 
 
1230
  segments.forEach(function(seg, idx) {{
1231
  const x1 = (seg[0] / duration) * W;
1232
  const x2 = (seg[1] / duration) * W;
1233
  ctx.fillStyle = segColors[idx % segColors.length];
1234
  ctx.fillRect(x1, 0, x2-x1, H);
 
1235
  ctx.fillStyle = 'rgba(255,255,255,0.6)';
1236
  ctx.font = '10px sans-serif';
1237
  ctx.fillText('Seg '+(idx+1), x1+3, 12);
1238
  }});
1239
 
 
1240
  const samples = channelData.length;
1241
+ const barW=2, gap=1, step=barW+gap;
1242
  const numBars = Math.floor(W / step);
1243
  const blockSz = Math.floor(samples / numBars);
1244
  ctx.fillStyle = '#4a9eff';
1245
+ for (let i=0; i<numBars; i++) {{
1246
+ let max=0;
1247
+ const s=i*blockSz;
1248
+ for (let j=0; j<blockSz; j++) {{
1249
+ const v=Math.abs(channelData[s+j]||0);
1250
+ if (v>max) max=v;
1251
  }}
1252
+ const barH=Math.max(1, max*H);
1253
+ ctx.fillRect(i*step, (H-barH)/2, barW, barH);
1254
  }}
1255
 
 
1256
  segments.forEach(function(seg) {{
1257
+ [seg[0],seg[1]].forEach(function(t) {{
1258
+ const x=(t/duration)*W;
1259
+ ctx.strokeStyle='rgba(255,255,255,0.4)';
1260
+ ctx.lineWidth=1;
1261
+ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
1262
  }});
1263
  }});
1264
 
1265
+ cv.onclick = function(e) {{
1266
+ const r=cv.getBoundingClientRect();
1267
+ const xRel=(e.clientX-r.left)/r.width;
1268
+ const tClick=xRel*duration;
1269
+ let hit=-1;
1270
+ segments.forEach(function(seg,idx){{ if(tClick>=seg[0]&&tClick<=seg[1]) hit=idx; }});
1271
+ if (hit>=0) showPopup(hit, e.clientX, e.clientY);
 
1272
  else hidePopup();
1273
  }};
1274
  }}
1275
 
 
1276
  function drawPlayhead(progress) {{
 
 
1277
  const dpr = window.devicePixelRatio || 1;
1278
+ const W = wrap.getBoundingClientRect().width || window.innerWidth || 600;
 
 
1279
  const H = 80;
1280
+ if (cvp.width !== W*dpr) {{ cvp.width=W*dpr; cvp.height=H*dpr; }}
1281
+ const ctx = cvp.getContext('2d');
1282
+ ctx.clearRect(0,0,W*dpr,H*dpr);
1283
+ ctx.save();
1284
+ ctx.scale(dpr,dpr);
1285
+ const x=progress*W;
1286
+ ctx.strokeStyle='#fff';
1287
+ ctx.lineWidth=2;
1288
+ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
1289
+ ctx.restore();
 
 
 
 
 
 
 
 
 
 
 
1290
  }}
1291
 
1292
+ // Poll parent for video time
1293
+ setInterval(function() {{
1294
+ try {{
1295
+ const vid = window.parent.document.querySelector('video');
1296
+ if (vid && vid.duration && isFinite(vid.duration) && audioDuration > 0) {{
1297
+ drawPlayhead(vid.currentTime / vid.duration);
 
1298
  }}
1299
+ }} catch(e) {{ /* cross-origin β€” ignore */ }}
1300
+ }}, 50);
 
 
 
 
1301
 
1302
+ // ── Decode audio ───────────────────────────────────────────────────
 
 
1303
  const b64str = '{b64}';
1304
+ console.log('[wf iframe {slot_id}] b64 len='+b64str.length);
1305
  const bin = atob(b64str);
1306
  const buf = new Uint8Array(bin.length);
1307
+ for (let i=0; i<bin.length; i++) buf[i]=bin.charCodeAt(i);
1308
+ console.log('[wf iframe {slot_id}] raw bytes='+buf.byteLength);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
 
1310
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
1311
+ if (!AudioCtx) {{
1312
+ console.warn('[wf iframe {slot_id}] No AudioContext');
1313
+ }} else {{
1314
+ const tmpCtx = new AudioCtx({{sampleRate:44100}});
1315
+ console.log('[wf iframe {slot_id}] calling decodeAudioData');
1316
  try {{
1317
+ tmpCtx.decodeAudioData(buf.buffer.slice(0),
1318
+ function(ab) {{
1319
+ console.log('[wf iframe {slot_id}] decoded OK duration='+ab.duration+'s');
1320
+ try {{ tmpCtx.close(); }} catch(e) {{}}
1321
+ function tryDraw() {{
1322
+ const W = wrap.getBoundingClientRect().width || window.innerWidth;
1323
+ console.log('[wf iframe {slot_id}] tryDraw W='+W);
1324
+ if (W > 0) {{ drawWaveform(ab.getChannelData(0), ab.duration); }}
1325
+ else {{ setTimeout(tryDraw, 100); }}
1326
+ }}
1327
+ tryDraw();
1328
+ }},
1329
+ function(err) {{ console.error('[wf iframe {slot_id}] decodeAudioData err:', err); }}
1330
+ );
1331
+ }} catch(e) {{ console.error('[wf iframe {slot_id}] decodeAudioData threw:', e); }}
1332
  }}
1333
  }})();
1334
  </script>
1335
+ </body>
1336
+ </html>"""
1337
+
1338
+ # Escape for HTML attribute (srcdoc uses HTML entities)
1339
+ import html as _html
1340
+ srcdoc = _html.escape(iframe_inner, quote=True)
1341
+
1342
+ return f"""
1343
+ <div id="wf_container_{slot_id}"
1344
+ style="background:#1a1a1a;border-radius:8px;padding:10px;margin-top:6px;position:relative;">
1345
+ <div style="position:relative;width:100%;height:80px;">
1346
+ <iframe id="wf_iframe_{slot_id}"
1347
+ srcdoc="{srcdoc}"
1348
+ sandbox="allow-scripts allow-same-origin"
1349
+ style="width:100%;height:80px;border:none;border-radius:4px;display:block;"
1350
+ scrolling="no"></iframe>
1351
+ </div>
1352
+ <div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
1353
+ <span style="color:#888;font-size:11px;">Click a segment to regenerate &nbsp;|&nbsp; Playhead syncs to video</span>
1354
+ <a href="{data_uri}" download="audio_{slot_id}.wav"
1355
+ style="margin-left:auto;background:#333;color:#eee;border:1px solid #555;
1356
+ border-radius:4px;padding:3px 10px;font-size:12px;text-decoration:none;">
1357
+ &#8595; Download</a>
1358
+ </div>
1359
+ <div id="wf_seglabel_{slot_id}"
1360
+ style="color:#aaa;font-size:11px;margin-top:4px;min-height:16px;"></div>
1361
+ </div>
1362
  """
1363
 
1364