Spaces:
Running on Zero
Running on Zero
Commit Β·
fcc4220
1
Parent(s): 6390bb5
Fix waveform rendering: use iframe srcdoc to ensure JS executes
Browse filesGradio'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.
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 |
-
|
| 1112 |
-
<
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
<
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1143 |
</div>
|
| 1144 |
<script>
|
| 1145 |
(function() {{
|
| 1146 |
-
|
| 1147 |
-
|
| 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
|
| 1157 |
-
|
| 1158 |
-
// ββ Popup
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 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 |
-
|
| 1179 |
-
|
| 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';
|
|
|
|
| 1184 |
requestAnimationFrame(function() {{
|
| 1185 |
const r=popup.getBoundingClientRect(), vw=window.innerWidth, vh=window.innerHeight;
|
| 1186 |
-
if (r.right>vw-8)
|
| 1187 |
-
if (r.bottom>vh-8) popup.style.top=(vh-r.height-8)+'px';
|
| 1188 |
}});
|
| 1189 |
}}
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1202 |
document.addEventListener('click', function(e) {{
|
| 1203 |
-
|
| 1204 |
-
if (p && p.style.display!=='none' && !p.contains(e.target)) hidePopup();
|
| 1205 |
}}, true);
|
| 1206 |
|
| 1207 |
-
// ββ Canvas waveform
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1214 |
-
const W = rect.width
|
| 1215 |
-
|| (canvas.parentElement ? canvas.parentElement.getBoundingClientRect().width : 0)
|
| 1216 |
-
|| 600;
|
| 1217 |
const H = 80;
|
| 1218 |
-
console.log('[
|
| 1219 |
-
|
| 1220 |
-
|
| 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
|
| 1244 |
const numBars = Math.floor(W / step);
|
| 1245 |
const blockSz = Math.floor(samples / numBars);
|
| 1246 |
ctx.fillStyle = '#4a9eff';
|
| 1247 |
-
for (let i
|
| 1248 |
-
let max
|
| 1249 |
-
const
|
| 1250 |
-
for (let j
|
| 1251 |
-
const v
|
| 1252 |
-
if (v
|
| 1253 |
}}
|
| 1254 |
-
const barH
|
| 1255 |
-
ctx.fillRect(i
|
| 1256 |
}}
|
| 1257 |
|
| 1258 |
-
// Segment boundary lines
|
| 1259 |
segments.forEach(function(seg) {{
|
| 1260 |
-
[seg[0],
|
| 1261 |
-
const x
|
| 1262 |
-
ctx.strokeStyle
|
| 1263 |
-
ctx.lineWidth
|
| 1264 |
-
ctx.beginPath(); ctx.moveTo(x,
|
| 1265 |
}});
|
| 1266 |
}});
|
| 1267 |
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
const
|
| 1271 |
-
const
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 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 =
|
| 1286 |
-
|| (canvas.parentElement ? canvas.parentElement.getBoundingClientRect().width : 0)
|
| 1287 |
-
|| 600;
|
| 1288 |
const H = 80;
|
| 1289 |
-
if (
|
| 1290 |
-
const ctx =
|
| 1291 |
-
ctx.clearRect(0,
|
| 1292 |
-
ctx.
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
ctx.
|
| 1296 |
-
ctx.
|
| 1297 |
-
|
| 1298 |
-
ctx.
|
| 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 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
drawPlayhead(video.currentTime / video.duration);
|
| 1319 |
}}
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
video.removeEventListener('timeupdate', onTimeUpdate);
|
| 1323 |
-
}};
|
| 1324 |
-
}})();
|
| 1325 |
-
}}
|
| 1326 |
|
| 1327 |
-
// ββ Decode audio
|
| 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('[
|
| 1332 |
const bin = atob(b64str);
|
| 1333 |
const buf = new Uint8Array(bin.length);
|
| 1334 |
-
for (let i
|
| 1335 |
-
console.log('[
|
| 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 |
-
|
| 1368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1369 |
try {{
|
| 1370 |
-
tmpCtx.decodeAudioData(buf.buffer.slice(0),
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 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">⟳ 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 | 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 |
+
↓ 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 |
|