Spaces:
Running on Zero
Running on Zero
Commit ·
4aa0f7d
1
Parent(s): 02a1f95
app.py
CHANGED
|
@@ -785,16 +785,24 @@ def _splice_and_save(new_wav, seg_idx, meta, slot_id):
|
|
| 785 |
else:
|
| 786 |
full_wav = full_wav[:n_total]
|
| 787 |
|
| 788 |
-
# Save new audio
|
|
|
|
|
|
|
|
|
|
| 789 |
tmp_dir = os.path.dirname(meta["audio_path"])
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
| 791 |
if stereo:
|
| 792 |
torchaudio.save(audio_path, torch.from_numpy(np.ascontiguousarray(full_wav)), sr)
|
| 793 |
else:
|
| 794 |
torchaudio.save(audio_path, torch.from_numpy(np.ascontiguousarray(full_wav)).unsqueeze(0), sr)
|
| 795 |
|
| 796 |
-
# Re-mux video
|
| 797 |
-
|
|
|
|
|
|
|
| 798 |
if model == "hunyuan":
|
| 799 |
# HunyuanFoley uses its own merge_audio_video
|
| 800 |
_hf_path = str(Path("HunyuanVideo-Foley").resolve())
|
|
@@ -1082,8 +1090,11 @@ _REGIONS_CDN = "https://cdnjs.cloudflare.com/ajax/libs/wavesurfer.js/7.8.7/pl
|
|
| 1082 |
|
| 1083 |
def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
| 1084 |
hidden_input_id: str) -> str:
|
| 1085 |
-
"""Return a self-contained HTML block with a WaveSurfer waveform,
|
| 1086 |
-
segment boundary markers,
|
|
|
|
|
|
|
|
|
|
| 1087 |
|
| 1088 |
Clicking a region shows a small popup near the cursor with a
|
| 1089 |
"Regenerate" button. Clicking elsewhere dismisses the popup.
|
|
@@ -1114,10 +1125,7 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
|
| 1114 |
style="background:#1a1a1a;border-radius:8px;padding:10px;margin-top:6px;position:relative;">
|
| 1115 |
<div id="wf_{slot_id}" style="width:100%;min-height:80px;"></div>
|
| 1116 |
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
| 1117 |
-
<
|
| 1118 |
-
style="background:#333;color:#eee;border:1px solid #555;border-radius:4px;
|
| 1119 |
-
padding:3px 10px;font-size:12px;cursor:pointer;">▶ Play</button>
|
| 1120 |
-
<span style="color:#888;font-size:11px;">Click a segment to regenerate</span>
|
| 1121 |
<a href="{data_uri}" download="audio_{slot_id}.wav"
|
| 1122 |
style="margin-left:auto;background:#333;color:#eee;border:1px solid #555;
|
| 1123 |
border-radius:4px;padding:3px 10px;font-size:12px;text-decoration:none;">
|
|
@@ -1142,9 +1150,17 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
|
| 1142 |
</div>
|
| 1143 |
<script>
|
| 1144 |
(function() {{
|
| 1145 |
-
//
|
| 1146 |
-
|
| 1147 |
-
window["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
|
| 1149 |
let _pendingSegIdx_{slot_id} = null;
|
| 1150 |
|
|
@@ -1160,10 +1176,14 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
|
| 1160 |
if (el) {{
|
| 1161 |
const input = el.querySelector('input, textarea');
|
| 1162 |
if (input) {{
|
| 1163 |
-
const
|
| 1164 |
-
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|
| 1165 |
-
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
|
| 1166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1167 |
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
| 1168 |
}}
|
| 1169 |
}}
|
|
@@ -1178,12 +1198,9 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
|
| 1178 |
'Seg ' + (idx+1) + ' (' + segs[idx][0].toFixed(2) + 's \u2013 ' + segs[idx][1].toFixed(2) + 's)';
|
| 1179 |
if (popup) {{
|
| 1180 |
popup.style.display = 'block';
|
| 1181 |
-
// Position near cursor, keep inside viewport
|
| 1182 |
const vw = window.innerWidth, vh = window.innerHeight;
|
| 1183 |
-
|
| 1184 |
-
popup.style.
|
| 1185 |
-
popup.style.top = y + 'px';
|
| 1186 |
-
// nudge back if off screen
|
| 1187 |
requestAnimationFrame(function() {{
|
| 1188 |
const r = popup.getBoundingClientRect();
|
| 1189 |
if (r.right > vw - 8) popup.style.left = (vw - r.width - 8) + 'px';
|
|
@@ -1198,15 +1215,7 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
|
| 1198 |
_pendingSegIdx_{slot_id} = null;
|
| 1199 |
}}
|
| 1200 |
|
| 1201 |
-
// Wire the Regenerate button
|
| 1202 |
-
document.addEventListener('DOMContentLoaded', function() {{
|
| 1203 |
-
const btn = document.getElementById('wf_regen_btn_{slot_id}');
|
| 1204 |
-
if (btn) btn.addEventListener('click', function(e) {{
|
| 1205 |
-
e.stopPropagation();
|
| 1206 |
-
if (_pendingSegIdx_{slot_id} !== null) fireRegen(_pendingSegIdx_{slot_id});
|
| 1207 |
-
}});
|
| 1208 |
-
}});
|
| 1209 |
-
// Also wire immediately in case DOM already loaded
|
| 1210 |
(function tryWireBtn() {{
|
| 1211 |
const btn = document.getElementById('wf_regen_btn_{slot_id}');
|
| 1212 |
if (btn) {{
|
|
@@ -1227,79 +1236,130 @@ def _build_waveform_html(audio_path: str, segments: list, slot_id: str,
|
|
| 1227 |
}}
|
| 1228 |
}}, true);
|
| 1229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
function loadWS() {{
|
| 1231 |
-
if (!window.WaveSurfer
|
| 1232 |
setTimeout(loadWS, 200);
|
| 1233 |
return;
|
| 1234 |
}}
|
| 1235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1236 |
const ws = WaveSurfer.create({{
|
| 1237 |
-
container:
|
| 1238 |
-
waveColor:
|
| 1239 |
-
progressColor:'#1a5fa8',
|
| 1240 |
-
height:
|
| 1241 |
-
barWidth:
|
| 1242 |
-
barGap:
|
| 1243 |
-
barRadius:
|
| 1244 |
-
|
| 1245 |
-
url:
|
| 1246 |
-
plugins:
|
| 1247 |
}});
|
| 1248 |
-
window["_wf_ws_{slot_id}"] = ws;
|
| 1249 |
-
window["wf_toggle_{slot_id}"] = function() {{ ws.playPause(); }};
|
| 1250 |
-
|
| 1251 |
-
const segments = {segs_json};
|
| 1252 |
-
const colors = {json.dumps(colors)};
|
| 1253 |
|
|
|
|
|
|
|
| 1254 |
ws.on('ready', function() {{
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
}});
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
}});
|
| 1273 |
|
| 1274 |
-
|
| 1275 |
-
const b = document.getElementById('wf_playbtn_{slot_id}');
|
| 1276 |
-
if (b) b.textContent = '\u23f8 Pause';
|
| 1277 |
-
}});
|
| 1278 |
-
ws.on('pause', function() {{
|
| 1279 |
-
const b = document.getElementById('wf_playbtn_{slot_id}');
|
| 1280 |
-
if (b) b.textContent = '\u25b6 Play';
|
| 1281 |
-
}});
|
| 1282 |
-
ws.on('finish', function() {{
|
| 1283 |
-
const b = document.getElementById('wf_playbtn_{slot_id}');
|
| 1284 |
-
if (b) b.textContent = '\u25b6 Play';
|
| 1285 |
-
}});
|
| 1286 |
}}
|
| 1287 |
|
| 1288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1289 |
const s = document.createElement('script');
|
| 1290 |
s.id = 'wavesurfer_script';
|
| 1291 |
s.src = '{_WAVESURFER_CDN}';
|
| 1292 |
s.onload = function() {{
|
|
|
|
|
|
|
|
|
|
| 1293 |
const r = document.createElement('script');
|
| 1294 |
r.id = 'wavesurfer_regions_script';
|
| 1295 |
r.src = '{_REGIONS_CDN}';
|
| 1296 |
-
r.onload =
|
| 1297 |
document.head.appendChild(r);
|
| 1298 |
}};
|
| 1299 |
document.head.appendChild(s);
|
| 1300 |
-
}} else {{
|
| 1301 |
-
loadWS();
|
| 1302 |
}}
|
|
|
|
|
|
|
| 1303 |
}})();
|
| 1304 |
</script>
|
| 1305 |
"""
|
|
|
|
| 785 |
else:
|
| 786 |
full_wav = full_wav[:n_total]
|
| 787 |
|
| 788 |
+
# Save new audio — use a new timestamped filename so Gradio / the browser
|
| 789 |
+
# treats it as a genuinely different file and reloads the video player.
|
| 790 |
+
import time as _time
|
| 791 |
+
_ts = int(_time.time() * 1000)
|
| 792 |
tmp_dir = os.path.dirname(meta["audio_path"])
|
| 793 |
+
_base = os.path.splitext(os.path.basename(meta["audio_path"]))[0]
|
| 794 |
+
# Strip any previous timestamp suffix before adding a new one
|
| 795 |
+
_base_clean = _base.rsplit("_regen_", 1)[0]
|
| 796 |
+
audio_path = os.path.join(tmp_dir, f"{_base_clean}_regen_{_ts}.wav")
|
| 797 |
if stereo:
|
| 798 |
torchaudio.save(audio_path, torch.from_numpy(np.ascontiguousarray(full_wav)), sr)
|
| 799 |
else:
|
| 800 |
torchaudio.save(audio_path, torch.from_numpy(np.ascontiguousarray(full_wav)).unsqueeze(0), sr)
|
| 801 |
|
| 802 |
+
# Re-mux into a new video file so the browser is forced to reload it
|
| 803 |
+
_vid_base = os.path.splitext(os.path.basename(meta["video_path"]))[0]
|
| 804 |
+
_vid_base_clean = _vid_base.rsplit("_regen_", 1)[0]
|
| 805 |
+
video_path = os.path.join(tmp_dir, f"{_vid_base_clean}_regen_{_ts}.mp4")
|
| 806 |
if model == "hunyuan":
|
| 807 |
# HunyuanFoley uses its own merge_audio_video
|
| 808 |
_hf_path = str(Path("HunyuanVideo-Foley").resolve())
|
|
|
|
| 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.
|
|
|
|
| 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 | Playhead syncs to video</span>
|
|
|
|
|
|
|
|
|
|
| 1129 |
<a href="{data_uri}" download="audio_{slot_id}.wav"
|
| 1130 |
style="margin-left:auto;background:#333;color:#eee;border:1px solid #555;
|
| 1131 |
border-radius:4px;padding:3px 10px;font-size:12px;text-decoration:none;">
|
|
|
|
| 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 |
|
|
|
|
| 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 |
}}
|
|
|
|
| 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';
|
|
|
|
| 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) {{
|
|
|
|
| 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 |
const ws = WaveSurfer.create({{
|
| 1294 |
+
container: '#wf_{slot_id}',
|
| 1295 |
+
waveColor: '#4a9eff',
|
| 1296 |
+
progressColor: '#1a5fa8',
|
| 1297 |
+
height: 80,
|
| 1298 |
+
barWidth: 2,
|
| 1299 |
+
barGap: 1,
|
| 1300 |
+
barRadius: 2,
|
| 1301 |
+
interact: false,
|
| 1302 |
+
url: '{data_uri}',
|
| 1303 |
+
plugins: plugins,
|
| 1304 |
}});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1305 |
|
| 1306 |
+
// Silence the WaveSurfer audio immediately and on ready
|
| 1307 |
+
ws.setVolume(0);
|
| 1308 |
ws.on('ready', function() {{
|
| 1309 |
+
ws.setVolume(0);
|
| 1310 |
+
const segments = {segs_json};
|
| 1311 |
+
const colors = {json.dumps(colors)};
|
| 1312 |
+
if (regionsPlugin) {{
|
| 1313 |
+
segments.forEach(function(seg, idx) {{
|
| 1314 |
+
regionsPlugin.addRegion({{
|
| 1315 |
+
id: 'seg_' + idx,
|
| 1316 |
+
start: seg[0],
|
| 1317 |
+
end: seg[1],
|
| 1318 |
+
color: colors[idx % colors.length],
|
| 1319 |
+
drag: false,
|
| 1320 |
+
resize: false,
|
| 1321 |
+
content: 'Seg ' + (idx + 1),
|
| 1322 |
+
}});
|
| 1323 |
}});
|
| 1324 |
+
regionsPlugin.on('region-clicked', function(region, e) {{
|
| 1325 |
+
e.stopPropagation();
|
| 1326 |
+
const idx = parseInt(region.id.replace('seg_', ''));
|
| 1327 |
+
showPopup(idx, e.clientX, e.clientY);
|
| 1328 |
+
}});
|
| 1329 |
+
}}
|
| 1330 |
+
attachVideoSync(ws);
|
| 1331 |
}});
|
| 1332 |
|
| 1333 |
+
window["_wf_ws_{slot_id}"] = ws;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1334 |
}}
|
| 1335 |
|
| 1336 |
+
// Load WaveSurfer + Regions scripts once (idempotent)
|
| 1337 |
+
function loadScripts(cb) {{
|
| 1338 |
+
if (window.WaveSurfer) {{ cb(); return; }}
|
| 1339 |
+
if (document.getElementById('wavesurfer_script')) {{
|
| 1340 |
+
// Script tag exists but WaveSurfer not yet defined — wait
|
| 1341 |
+
const t = setInterval(function() {{
|
| 1342 |
+
if (window.WaveSurfer) {{ clearInterval(t); cb(); }}
|
| 1343 |
+
}}, 100);
|
| 1344 |
+
return;
|
| 1345 |
+
}}
|
| 1346 |
const s = document.createElement('script');
|
| 1347 |
s.id = 'wavesurfer_script';
|
| 1348 |
s.src = '{_WAVESURFER_CDN}';
|
| 1349 |
s.onload = function() {{
|
| 1350 |
+
if (document.getElementById('wavesurfer_regions_script')) {{
|
| 1351 |
+
cb(); return;
|
| 1352 |
+
}}
|
| 1353 |
const r = document.createElement('script');
|
| 1354 |
r.id = 'wavesurfer_regions_script';
|
| 1355 |
r.src = '{_REGIONS_CDN}';
|
| 1356 |
+
r.onload = cb;
|
| 1357 |
document.head.appendChild(r);
|
| 1358 |
}};
|
| 1359 |
document.head.appendChild(s);
|
|
|
|
|
|
|
| 1360 |
}}
|
| 1361 |
+
|
| 1362 |
+
loadScripts(loadWS);
|
| 1363 |
}})();
|
| 1364 |
</script>
|
| 1365 |
"""
|