BoxOfColors commited on
Commit
504a99f
Β·
1 Parent(s): 78d7cea
Files changed (1) hide show
  1. app.py +163 -236
app.py CHANGED
@@ -1085,26 +1085,14 @@ def _pad_outputs(outputs: list) -> list:
1085
  # WaveSurfer waveform + segment marker HTML builder #
1086
  # ------------------------------------------------------------------ #
1087
 
1088
- _WAVESURFER_CDN = "https://cdnjs.cloudflare.com/ajax/libs/wavesurfer.js/7.8.7/wavesurfer.min.js"
1089
- _REGIONS_CDN = "https://cdnjs.cloudflare.com/ajax/libs/wavesurfer.js/7.8.7/plugins/regions.min.js"
1090
-
1091
  def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
1092
  hidden_input_id: str) -> str:
1093
- """Return a self-contained HTML block with a WaveSurfer waveform (display only),
1094
  segment boundary markers, and a download link.
1095
 
1096
- The waveform is SILENT β€” no audio plays from it. The playhead tracks the
1097
- Gradio <video> element in the same slot via its `timeupdate` event.
1098
-
1099
- Clicking a region shows a small popup near the cursor with a
1100
- "Regenerate" button. Clicking elsewhere dismisses the popup.
1101
- Clicking "Regenerate" fires the hidden Gradio textbox to trigger Python.
1102
-
1103
- Args:
1104
- audio_path: absolute path to the .wav file
1105
- segments: list of (start_s, end_s) tuples
1106
- slot_id: unique string id for this slot (e.g. "taro_0")
1107
- hidden_input_id: elem_id of the hidden gr.Textbox to fire
1108
  """
1109
  if not audio_path or not os.path.exists(audio_path):
1110
  return "<p style='color:#888;font-size:12px'>No audio yet.</p>"
@@ -1115,15 +1103,20 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
1115
 
1116
  segs_json = json.dumps(segments)
1117
 
1118
- colors = ["rgba(100,180,255,0.22)", "rgba(255,160,100,0.22)",
1119
- "rgba(120,220,140,0.22)", "rgba(220,120,220,0.22)",
1120
- "rgba(255,220,80,0.22)", "rgba(80,220,220,0.22)",
1121
- "rgba(255,100,100,0.22)", "rgba(180,255,180,0.22)"]
1122
 
1123
  return f"""
1124
  <div id="wf_container_{slot_id}"
1125
  style="background:#1a1a1a;border-radius:8px;padding:10px;margin-top:6px;position:relative;">
1126
- <div id="wf_{slot_id}" style="width:100%;min-height:80px;"></div>
 
 
 
 
 
1127
  <div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
1128
  <span style="color:#888;font-size:11px;">Click a segment to regenerate &nbsp;|&nbsp; Playhead syncs to video</span>
1129
  <a href="{data_uri}" download="audio_{slot_id}.wav"
@@ -1150,260 +1143,207 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
1150
  </div>
1151
  <script>
1152
  (function() {{
1153
- // Each time this HTML is injected (new audio or regen), destroy any previous
1154
- // WaveSurfer instance for this slot so we get a fresh waveform.
1155
- if (window["_wf_ws_{slot_id}"]) {{
1156
- try {{ window["_wf_ws_{slot_id}"].destroy(); }} catch(e) {{}}
1157
- window["_wf_ws_{slot_id}"] = null;
1158
- }}
1159
- // Remove previous video timeupdate listener if any
1160
  if (window["_wf_video_unlisten_{slot_id}"]) {{
1161
  try {{ window["_wf_video_unlisten_{slot_id}"](); }} catch(e) {{}}
1162
  window["_wf_video_unlisten_{slot_id}"] = null;
1163
  }}
1164
 
1165
- let _pendingSegIdx_{slot_id} = null;
 
 
 
1166
 
 
1167
  function fireRegen(idx) {{
1168
- const popup = document.getElementById('wf_popup_{slot_id}');
1169
- if (popup) popup.style.display = 'none';
1170
  const lbl = document.getElementById('wf_seglabel_{slot_id}');
1171
- const segs = {segs_json};
1172
  if (lbl) lbl.textContent = 'Regenerating Seg ' + (idx+1) +
1173
- ' (' + segs[idx][0].toFixed(2) + 's \u2013 ' + segs[idx][1].toFixed(2) + 's)\u2026';
1174
- // Trigger Gradio via the hidden textbox
1175
  const el = document.getElementById('{hidden_input_id}');
1176
  if (el) {{
1177
  const input = el.querySelector('input, textarea');
1178
  if (input) {{
1179
- const nativeSetter =
1180
- Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') ||
1181
- Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
1182
- if (nativeSetter && nativeSetter.set) {{
1183
- nativeSetter.set.call(input, '{slot_id}|' + idx);
1184
- }} else {{
1185
- input.value = '{slot_id}|' + idx;
1186
- }}
1187
- input.dispatchEvent(new Event('input', {{ bubbles: true }}));
1188
  }}
1189
  }}
1190
  }}
1191
 
1192
- function showPopup(idx, mouseX, mouseY) {{
1193
  _pendingSegIdx_{slot_id} = idx;
1194
- const segs = {segs_json};
1195
  const popup = document.getElementById('wf_popup_{slot_id}');
1196
  const plbl = document.getElementById('wf_popup_label_{slot_id}');
1197
- if (plbl) plbl.textContent =
1198
- 'Seg ' + (idx+1) + ' (' + segs[idx][0].toFixed(2) + 's \u2013 ' + segs[idx][1].toFixed(2) + 's)';
1199
- if (popup) {{
1200
- popup.style.display = 'block';
1201
- const vw = window.innerWidth, vh = window.innerHeight;
1202
- popup.style.left = (mouseX + 10) + 'px';
1203
- popup.style.top = (mouseY + 10) + 'px';
1204
- requestAnimationFrame(function() {{
1205
- const r = popup.getBoundingClientRect();
1206
- if (r.right > vw - 8) popup.style.left = (vw - r.width - 8) + 'px';
1207
- if (r.bottom > vh - 8) popup.style.top = (vh - r.height - 8) + 'px';
1208
- }});
1209
- }}
1210
  }}
1211
 
1212
  function hidePopup() {{
1213
- const popup = document.getElementById('wf_popup_{slot_id}');
1214
- if (popup) popup.style.display = 'none';
1215
  _pendingSegIdx_{slot_id} = null;
1216
  }}
1217
 
1218
- // Wire the Regenerate button (poll until the element exists)
1219
  (function tryWireBtn() {{
1220
  const btn = document.getElementById('wf_regen_btn_{slot_id}');
1221
- if (btn) {{
1222
- btn.onclick = function(e) {{
1223
- e.stopPropagation();
1224
- if (_pendingSegIdx_{slot_id} !== null) fireRegen(_pendingSegIdx_{slot_id});
1225
- }};
1226
- }} else {{
1227
- setTimeout(tryWireBtn, 100);
1228
- }}
1229
  }})();
1230
 
1231
- // Dismiss popup on click outside
1232
  document.addEventListener('click', function(e) {{
1233
- const popup = document.getElementById('wf_popup_{slot_id}');
1234
- if (popup && popup.style.display !== 'none') {{
1235
- if (!popup.contains(e.target)) hidePopup();
1236
- }}
1237
  }}, true);
1238
 
1239
- // -----------------------------------------------------------------------
1240
- // Find the <video> element that lives in the same Gradio slot as this
1241
- // waveform. Walk up the DOM from the waveform container until we find
1242
- // a parent that also contains a <video> tag.
1243
- // -----------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1244
  function findSlotVideo() {{
1245
- const wfEl = document.getElementById('wf_container_{slot_id}');
1246
- if (!wfEl) return null;
1247
- let node = wfEl.parentElement;
1248
  while (node && node !== document.body) {{
1249
- const vid = node.querySelector('video');
1250
- if (vid) return vid;
1251
  node = node.parentElement;
1252
  }}
1253
  return null;
1254
  }}
1255
 
1256
- function attachVideoSync(ws) {{
1257
- // Poll until the video element appears (Gradio may render it after the HTML)
1258
- function tryAttach() {{
1259
  const video = findSlotVideo();
1260
- if (!video) {{
1261
- setTimeout(tryAttach, 300);
1262
- return;
1263
- }}
1264
  function onTimeUpdate() {{
1265
  if (!video.duration || !isFinite(video.duration)) return;
1266
- const progress = video.currentTime / video.duration;
1267
- try {{ ws.seekTo(Math.max(0, Math.min(1, progress))); }} catch(e) {{}}
1268
  }}
1269
  video.addEventListener('timeupdate', onTimeUpdate);
1270
- // Store cleanup fn so we can detach when this HTML is replaced
1271
  window["_wf_video_unlisten_{slot_id}"] = function() {{
1272
  video.removeEventListener('timeupdate', onTimeUpdate);
1273
  }};
1274
- }}
1275
- tryAttach();
1276
  }}
1277
 
1278
- function loadWS() {{
1279
- if (!window.WaveSurfer) {{
1280
- setTimeout(loadWS, 200);
1281
- return;
1282
- }}
1283
-
1284
- // Use MediaElement backend so WaveSurfer renders the waveform from the
1285
- // audio data but we keep the backing <audio> muted β€” no sound output.
1286
- let plugins = [];
1287
- let regionsPlugin = null;
1288
- if (window.WaveSurfer.Regions) {{
1289
- regionsPlugin = window.WaveSurfer.Regions.create();
1290
- plugins = [regionsPlugin];
1291
- }}
1292
-
1293
- // Convert base64 audio to a Blob URL β€” WaveSurfer v7 handles blob: URLs
1294
- // much more reliably than data: URIs for waveform rendering.
1295
- const b64 = '{b64}';
1296
- const byteChars = atob(b64);
1297
- const byteNums = new Uint8Array(byteChars.length);
1298
- for (let i = 0; i < byteChars.length; i++) byteNums[i] = byteChars.charCodeAt(i);
1299
- const blob = new Blob([byteNums], {{type: 'audio/wav'}});
1300
- const blobUrl = URL.createObjectURL(blob);
1301
-
1302
- // Wait until the container has a non-zero width before creating WaveSurfer,
1303
- // otherwise it renders blank (container is 0px when Gradio first injects HTML).
1304
- const wfContainer = document.getElementById('wf_{slot_id}');
1305
- function createWS() {{
1306
- const ws = WaveSurfer.create({{
1307
- container: '#wf_{slot_id}',
1308
- waveColor: '#4a9eff',
1309
- progressColor: '#1a5fa8',
1310
- height: 80,
1311
- barWidth: 2,
1312
- barGap: 1,
1313
- barRadius: 2,
1314
- url: blobUrl,
1315
- plugins: plugins,
1316
- }});
1317
-
1318
- // Silence immediately β€” waveform is display-only, video plays the audio
1319
- ws.setVolume(0);
1320
- ws.on('ready', function() {{
1321
- ws.setVolume(0);
1322
- ws.pause();
1323
- const segments = {segs_json};
1324
- const colors = {json.dumps(colors)};
1325
- if (regionsPlugin) {{
1326
- segments.forEach(function(seg, idx) {{
1327
- regionsPlugin.addRegion({{
1328
- id: 'seg_' + idx,
1329
- start: seg[0],
1330
- end: seg[1],
1331
- color: colors[idx % colors.length],
1332
- drag: false,
1333
- resize: false,
1334
- content: 'Seg ' + (idx + 1),
1335
- }});
1336
- }});
1337
- regionsPlugin.on('region-clicked', function(region, e) {{
1338
- e.stopPropagation();
1339
- const idx = parseInt(region.id.replace('seg_', ''));
1340
- showPopup(idx, e.clientX, e.clientY);
1341
- }});
1342
- }}
1343
- attachVideoSync(ws);
1344
- }});
1345
-
1346
- ws.on('error', function(err) {{
1347
- console.error('[WaveSurfer {slot_id}] error:', err);
1348
- }});
1349
-
1350
- // Also silence if WaveSurfer tries to play for any reason
1351
- ws.on('play', function() {{ ws.pause(); ws.setVolume(0); }});
1352
-
1353
- window["_wf_ws_{slot_id}"] = ws;
1354
- }}
1355
-
1356
- // Use ResizeObserver: fire createWS as soon as the container has width
1357
- if (wfContainer && wfContainer.offsetWidth > 0) {{
1358
- createWS();
1359
- }} else if (wfContainer) {{
1360
- const ro = new ResizeObserver(function(entries) {{
1361
- for (const entry of entries) {{
1362
- if (entry.contentRect.width > 0) {{
1363
- ro.disconnect();
1364
- createWS();
1365
- break;
1366
- }}
1367
- }}
1368
- }});
1369
- ro.observe(wfContainer);
1370
- // Fallback after 1s in case ResizeObserver fires late
1371
- setTimeout(function() {{
1372
- if (!window["_wf_ws_{slot_id}"]) {{ ro.disconnect(); createWS(); }}
1373
- }}, 1000);
1374
- }} else {{
1375
- // Container not in DOM yet β€” wait for it
1376
- setTimeout(loadWS, 200);
1377
- }}
1378
  }}
1379
 
1380
- // Load WaveSurfer + Regions scripts once (idempotent)
1381
- function loadScripts(cb) {{
1382
- if (window.WaveSurfer) {{ cb(); return; }}
1383
- if (document.getElementById('wavesurfer_script')) {{
1384
- // Script tag exists but WaveSurfer not yet defined β€” wait
1385
- const t = setInterval(function() {{
1386
- if (window.WaveSurfer) {{ clearInterval(t); cb(); }}
1387
- }}, 100);
1388
- return;
1389
- }}
1390
- const s = document.createElement('script');
1391
- s.id = 'wavesurfer_script';
1392
- s.src = '{_WAVESURFER_CDN}';
1393
- s.onload = function() {{
1394
- if (document.getElementById('wavesurfer_regions_script')) {{
1395
- cb(); return;
1396
- }}
1397
- const r = document.createElement('script');
1398
- r.id = 'wavesurfer_regions_script';
1399
- r.src = '{_REGIONS_CDN}';
1400
- r.onload = cb;
1401
- document.head.appendChild(r);
1402
- }};
1403
- document.head.appendChild(s);
1404
  }}
1405
-
1406
- loadScripts(loadWS);
1407
  }})();
1408
  </script>
1409
  """
@@ -1486,20 +1426,7 @@ def _update_slot_visibility(n):
1486
  # GRADIO UI #
1487
  # ================================================================== #
1488
 
1489
- _SLOT_CSS = """
1490
- /* Cap the entire video component (including the loading/processing state)
1491
- so the waveform below is never pushed out of view */
1492
- .gradio-video {
1493
- max-height: 380px !important;
1494
- overflow: hidden;
1495
- }
1496
- .gradio-video video,
1497
- .gradio-video .video-container,
1498
- .gradio-video .wrap {
1499
- max-height: 340px !important;
1500
- object-fit: contain;
1501
- }
1502
- """
1503
 
1504
  with gr.Blocks(title="Generate Audio for Video", css=_SLOT_CSS) as demo:
1505
  gr.Markdown(
 
1085
  # WaveSurfer waveform + segment marker HTML builder #
1086
  # ------------------------------------------------------------------ #
1087
 
 
 
 
1088
  def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
1089
  hidden_input_id: str) -> str:
1090
+ """Return a self-contained HTML block with a Canvas waveform (display only),
1091
  segment boundary markers, and a download link.
1092
 
1093
+ Uses Web Audio API + Canvas β€” no external libraries.
1094
+ The waveform is SILENT. The playhead tracks the Gradio <video> element
1095
+ in the same slot via its timeupdate event.
 
 
 
 
 
 
 
 
 
1096
  """
1097
  if not audio_path or not os.path.exists(audio_path):
1098
  return "<p style='color:#888;font-size:12px'>No audio yet.</p>"
 
1103
 
1104
  segs_json = json.dumps(segments)
1105
 
1106
+ seg_colors = ["rgba(100,180,255,0.35)", "rgba(255,160,100,0.35)",
1107
+ "rgba(120,220,140,0.35)", "rgba(220,120,220,0.35)",
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"
 
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
+ const segments = {segs_json};
1153
+ const segColors = {json.dumps(seg_colors)};
1154
+ let audioDuration = 0;
1155
+ let _pendingSegIdx_{slot_id} = null;
1156
 
1157
+ // ── Popup helpers ──────────────────────────────────────────────────
1158
  function fireRegen(idx) {{
1159
+ document.getElementById('wf_popup_{slot_id}').style.display = 'none';
 
1160
  const lbl = document.getElementById('wf_seglabel_{slot_id}');
 
1161
  if (lbl) lbl.textContent = 'Regenerating Seg ' + (idx+1) +
1162
+ ' (' + segments[idx][0].toFixed(2) + 's \u2013 ' + segments[idx][1].toFixed(2) + 's)\u2026';
 
1163
  const el = document.getElementById('{hidden_input_id}');
1164
  if (el) {{
1165
  const input = el.querySelector('input, textarea');
1166
  if (input) {{
1167
+ const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
1168
+ || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
1169
+ if (desc && desc.set) desc.set.call(input, '{slot_id}|' + idx);
1170
+ else input.value = '{slot_id}|' + idx;
1171
+ input.dispatchEvent(new Event('input', {{bubbles:true}}));
 
 
 
 
1172
  }}
1173
  }}
1174
  }}
1175
 
1176
+ function showPopup(idx, mx, my) {{
1177
  _pendingSegIdx_{slot_id} = idx;
 
1178
  const popup = document.getElementById('wf_popup_{slot_id}');
1179
  const plbl = document.getElementById('wf_popup_label_{slot_id}');
1180
+ if (plbl) plbl.textContent = 'Seg '+(idx+1)+' ('+segments[idx][0].toFixed(2)+'s \u2013 '+segments[idx][1].toFixed(2)+'s)';
1181
+ popup.style.display = 'block';
1182
+ popup.style.left = (mx+10)+'px'; popup.style.top = (my+10)+'px';
1183
+ requestAnimationFrame(function() {{
1184
+ const r=popup.getBoundingClientRect(), vw=window.innerWidth, vh=window.innerHeight;
1185
+ if (r.right>vw-8) popup.style.left=(vw-r.width-8)+'px';
1186
+ if (r.bottom>vh-8) popup.style.top=(vh-r.height-8)+'px';
1187
+ }});
 
 
 
 
 
1188
  }}
1189
 
1190
  function hidePopup() {{
1191
+ document.getElementById('wf_popup_{slot_id}').style.display='none';
 
1192
  _pendingSegIdx_{slot_id} = null;
1193
  }}
1194
 
 
1195
  (function tryWireBtn() {{
1196
  const btn = document.getElementById('wf_regen_btn_{slot_id}');
1197
+ if (btn) {{ btn.onclick = function(e) {{ e.stopPropagation(); if (_pendingSegIdx_{slot_id}!==null) fireRegen(_pendingSegIdx_{slot_id}); }}; }}
1198
+ else setTimeout(tryWireBtn, 100);
 
 
 
 
 
 
1199
  }})();
1200
 
 
1201
  document.addEventListener('click', function(e) {{
1202
+ const p=document.getElementById('wf_popup_{slot_id}');
1203
+ if (p && p.style.display!=='none' && !p.contains(e.target)) hidePopup();
 
 
1204
  }}, true);
1205
 
1206
+ // ── Canvas waveform drawing ────────────────────────────────────────
1207
+ function drawWaveform(channelData, duration) {{
1208
+ audioDuration = duration;
1209
+ const canvas = document.getElementById('wf_canvas_{slot_id}');
1210
+ if (!canvas) return;
1211
+ const dpr = window.devicePixelRatio || 1;
1212
+ const W = canvas.offsetWidth || canvas.parentElement.offsetWidth || 600;
1213
+ const H = 80;
1214
+ canvas.width = W * dpr;
1215
+ canvas.height = H * dpr;
1216
+ const ctx = canvas.getContext('2d');
1217
+ ctx.scale(dpr, dpr);
1218
+
1219
+ // Background
1220
+ ctx.fillStyle = '#1e1e2e';
1221
+ ctx.fillRect(0, 0, W, H);
1222
+
1223
+ // Segment region overlays
1224
+ segments.forEach(function(seg, idx) {{
1225
+ const x1 = (seg[0] / duration) * W;
1226
+ const x2 = (seg[1] / duration) * W;
1227
+ ctx.fillStyle = segColors[idx % segColors.length];
1228
+ ctx.fillRect(x1, 0, x2-x1, H);
1229
+ // Segment label
1230
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
1231
+ ctx.font = '10px sans-serif';
1232
+ ctx.fillText('Seg '+(idx+1), x1+3, 12);
1233
+ }});
1234
+
1235
+ // Waveform bars
1236
+ const samples = channelData.length;
1237
+ const barW = 2, gap = 1, step = barW + gap;
1238
+ const numBars = Math.floor(W / step);
1239
+ const blockSz = Math.floor(samples / numBars);
1240
+ ctx.fillStyle = '#4a9eff';
1241
+ for (let i = 0; i < numBars; i++) {{
1242
+ let max = 0;
1243
+ const start = i * blockSz;
1244
+ for (let j = 0; j < blockSz; j++) {{
1245
+ const v = Math.abs(channelData[start + j] || 0);
1246
+ if (v > max) max = v;
1247
+ }}
1248
+ const barH = Math.max(1, max * H);
1249
+ ctx.fillRect(i * step, (H - barH) / 2, barW, barH);
1250
+ }}
1251
+
1252
+ // Segment boundary lines
1253
+ segments.forEach(function(seg) {{
1254
+ [seg[0], seg[1]].forEach(function(t) {{
1255
+ const x = (t / duration) * W;
1256
+ ctx.strokeStyle = 'rgba(255,255,255,0.4)';
1257
+ ctx.lineWidth = 1;
1258
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
1259
+ }});
1260
+ }});
1261
+
1262
+ // Click handler for segment selection
1263
+ canvas.onclick = function(e) {{
1264
+ const rect = canvas.getBoundingClientRect();
1265
+ const xRel = (e.clientX - rect.left) / rect.width;
1266
+ const tClick = xRel * duration;
1267
+ let hit = -1;
1268
+ segments.forEach(function(seg, idx) {{ if (tClick >= seg[0] && tClick <= seg[1]) hit = idx; }});
1269
+ if (hit >= 0) showPopup(hit, e.clientX, e.clientY);
1270
+ else hidePopup();
1271
+ }};
1272
+ }}
1273
+
1274
+ // ── Playhead drawing ──────────────────────────────────────────────
1275
+ function drawPlayhead(progress) {{
1276
+ const canvas = document.getElementById('wf_playhead_{slot_id}');
1277
+ if (!canvas) return;
1278
+ const dpr = window.devicePixelRatio || 1;
1279
+ const W = canvas.offsetWidth || canvas.parentElement.offsetWidth || 600;
1280
+ const H = 80;
1281
+ if (canvas.width !== W*dpr) {{ canvas.width=W*dpr; canvas.height=H*dpr; }}
1282
+ const ctx = canvas.getContext('2d');
1283
+ ctx.clearRect(0, 0, W*dpr, H*dpr);
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
+ // Reset transform for next call
1290
+ ctx.setTransform(1,0,0,1,0,0);
1291
+ }}
1292
+
1293
+ // ── Video sync ────────────────────────────────────────────────────
1294
  function findSlotVideo() {{
1295
+ let node = document.getElementById('wf_container_{slot_id}');
 
 
1296
  while (node && node !== document.body) {{
1297
+ const v = node.querySelector('video');
1298
+ if (v) return v;
1299
  node = node.parentElement;
1300
  }}
1301
  return null;
1302
  }}
1303
 
1304
+ function attachVideoSync() {{
1305
+ (function tryAttach() {{
 
1306
  const video = findSlotVideo();
1307
+ if (!video) {{ setTimeout(tryAttach, 300); return; }}
 
 
 
1308
  function onTimeUpdate() {{
1309
  if (!video.duration || !isFinite(video.duration)) return;
1310
+ drawPlayhead(video.currentTime / video.duration);
 
1311
  }}
1312
  video.addEventListener('timeupdate', onTimeUpdate);
 
1313
  window["_wf_video_unlisten_{slot_id}"] = function() {{
1314
  video.removeEventListener('timeupdate', onTimeUpdate);
1315
  }};
1316
+ }})();
 
1317
  }}
1318
 
1319
+ // ── Decode audio & render ─────────────────────────────────────────
1320
+ function init() {{
1321
+ const b64str = '{b64}';
1322
+ // decode base64 β†’ ArrayBuffer
1323
+ const bin = atob(b64str);
1324
+ const buf = new Uint8Array(bin.length);
1325
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
1326
+
1327
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
1328
+ if (!AudioCtx) {{ console.warn('No AudioContext'); return; }}
1329
+ const actx = new AudioCtx();
1330
+ actx.decodeAudioData(buf.buffer, function(audioBuffer) {{
1331
+ const channelData = audioBuffer.getChannelData(0);
1332
+ drawWaveform(channelData, audioBuffer.duration);
1333
+ attachVideoSync();
1334
+ actx.close();
1335
+ }}, function(err) {{
1336
+ console.error('[waveform {slot_id}] decodeAudioData error:', err);
1337
+ }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1338
  }}
1339
 
1340
+ // Run after the canvas is in the DOM and has layout
1341
+ if (document.readyState === 'loading') {{
1342
+ document.addEventListener('DOMContentLoaded', init);
1343
+ }} else {{
1344
+ // Small delay to let Gradio finish laying out the component
1345
+ setTimeout(init, 100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1346
  }}
 
 
1347
  }})();
1348
  </script>
1349
  """
 
1426
  # GRADIO UI #
1427
  # ================================================================== #
1428
 
1429
+ _SLOT_CSS = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
1430
 
1431
  with gr.Blocks(title="Generate Audio for Video", css=_SLOT_CSS) as demo:
1432
  gr.Markdown(