Files
operating-room-monitor-server/clients/demo-client/app.js
Kevin 153c91f8ff 将 Demo 录制收敛为三条独立链路,并重做联调台 UI。
移除 demo_orch 统一编排,改为 recording_demo 与 live/simulated 服务;客户端拆分为静态资源,以模式卡片与 chip 耗材覆盖三链路联调,并同步测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 16:50:23 +08:00

790 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Operation Room Monitor — Demo 联调台
*/
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
const DEF_CAMS = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"];
const DEF_RP = ["demo1", "demo2", "demo3", "demo4"];
let activeMode = "live-rtsp";
let allLabels = [];
let selectedConsumables = new Set();
let lastVideoBatchDoctorDisplay = "";
const webcamSlotState = {};
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
const surgeryId = () => $("surgery-id").value.trim();
function inferDefaultApiBase() {
try {
const host = location.hostname || "127.0.0.1";
const scheme = location.protocol === "https:" ? "https" : "http";
return `${scheme}://${host}:38080`;
} catch {
return "http://127.0.0.1:38080";
}
}
function isLoopbackOrWildcardHost(hostname) {
const h = String(hostname || "").trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
}
function applyLanDefaultApiBase() {
const input = $("base-url");
const pageHost = location.hostname || "";
try {
const current = new URL(input.value);
if (!isLoopbackOrWildcardHost(pageHost) && isLoopbackOrWildcardHost(current.hostname)) {
input.value = inferDefaultApiBase();
}
} catch {
input.value = inferDefaultApiBase();
}
}
function getRunMode() {
return activeMode;
}
function showBanner(msg, type) {
const el = $("banner");
if (!el) return;
el.textContent = msg;
el.className = "banner show " + (type || "info");
}
function setLoading(btn, on) {
if (!btn) return;
btn.disabled = on;
btn.classList.toggle("loading", on);
}
function formatDetail(body) {
if (body == null) return "错误";
const detail = body.detail !== undefined ? body.detail : body;
if (typeof detail === "object" && detail !== null) {
return JSON.stringify(detail, null, 2);
}
return String(detail || body);
}
const logEl = $("log-scroll");
function addLog(method, url, status, body, { error = false, hint = "" } = {}) {
const item = document.createElement("div");
item.className = "log-item";
const time = new Date().toLocaleTimeString();
const statusClass = error || (typeof status === "number" && status >= 400) ? "err" : "ok";
const statusText = typeof status === "number" ? status : status || (error ? "ERROR" : "—");
item.innerHTML = `
<div class="log-head">
<span><span class="log-method">${method}</span> <span>${url}</span></span>
<span class="log-status ${statusClass}">${statusText} <span>${time}</span></span>
</div>`;
const bodyEl = document.createElement("div");
bodyEl.className = "log-body";
if (body == null || body === "") bodyEl.textContent = "(empty)";
else if (typeof body === "string") bodyEl.textContent = body;
else {
try {
bodyEl.textContent = JSON.stringify(body, null, 2);
} catch {
bodyEl.textContent = String(body);
}
}
item.appendChild(bodyEl);
if (hint) {
const h = document.createElement("div");
h.className = "log-hint";
h.textContent = hint;
item.appendChild(h);
}
if (logEl) logEl.insertBefore(item, logEl.firstChild);
}
async function apiJson(method, path, payload) {
const url = baseUrl() + path;
let res;
try {
res = await fetch(url, {
method,
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
} catch (e) {
addLog(method, url, "NETWORK", String(e), { error: true });
throw e;
}
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
addLog(method, url, res.status, parsed, { error: !res.ok });
return { res, body: parsed };
}
async function apiMultipart(path, formData, logLabel) {
const url = baseUrl() + path;
const label = logLabel || "multipart";
let res;
try {
res = await fetch(url, { method: "POST", body: formData });
} catch (e) {
const hint = "无法连接 API请确认 Base URL 指向主服务(默认 :38080。";
addLog("POST (" + label + ")", url, "NETWORK", String(e), { error: true, hint });
throw e;
}
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
let hint = "";
if (res.status === 404) {
hint = "Demo 录制模式未注册,请设 DEMO_ORCHESTRATOR_ENABLED=true 并重启。";
} else if (
res.status === 400 &&
parsed &&
String(parsed.detail || "").indexOf("OR_SITE_CONFIG") >= 0
) {
hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE。";
}
addLog("POST (" + label + ")", url, res.status, parsed, { error: !res.ok, hint });
return { res, body: parsed };
}
async function apiOfflineBatch(formData) {
return apiMultipart("/internal/demo/offline-batch", formData, "offline-batch");
}
function ensureSurgeryId() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) {
showBanner("手术号必须是 6 位数字", "err");
return null;
}
return sid;
}
function formatConsumablesJson(arr) {
return JSON.stringify(arr, null, 2);
}
function parseConsumablesFromJsonText(raw) {
if (!raw.trim()) return [];
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error("JSON 格式错误:" + (e.message || String(e)));
}
if (!Array.isArray(parsed)) throw new Error("必须是 JSON 数组");
const out = [];
for (let i = 0; i < parsed.length; i++) {
const x = parsed[i];
if (typeof x === "string") {
const s = x.trim();
if (s) out.push(s);
continue;
}
if (x !== null && typeof x === "object" && !Array.isArray(x)) {
const name = x["名称"] != null ? x["名称"] : x["name"];
if (typeof name === "string") {
const s = name.trim();
if (s) out.push(s);
}
}
}
return out;
}
function getSelectedConsumables() {
const ta = $("candidate-consumables-json");
const advanced = ta?.closest("details.advanced");
if (ta && (advanced?.open || document.activeElement === ta)) {
return parseConsumablesFromJsonText(ta.value);
}
return [...selectedConsumables];
}
function syncJsonFromChips() {
const ta = $("candidate-consumables-json");
if (ta) ta.value = formatConsumablesJson(getSelectedConsumables());
}
function syncChipsFromJson() {
try {
const arr = parseConsumablesFromJsonText($("candidate-consumables-json").value);
selectedConsumables = new Set(arr);
renderConsumableChips();
} catch {
/* ignore while typing invalid json */
}
}
function renderConsumableChips() {
const container = $("chips");
const q = ($("chip-search")?.value || "").trim().toLowerCase();
if (!container) return;
container.innerHTML = "";
const filtered = allLabels.filter((l) => !q || l.toLowerCase().includes(q));
filtered.forEach((label) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip" + (selectedConsumables.has(label) ? " selected" : "");
chip.textContent = label;
chip.onclick = () => {
if (selectedConsumables.has(label)) selectedConsumables.delete(label);
else selectedConsumables.add(label);
renderConsumableChips();
syncJsonFromChips();
};
container.appendChild(chip);
});
const meta = $("labels-meta");
if (meta) {
meta.textContent = `已选 ${selectedConsumables.size} / 共 ${allLabels.length} 个标签`;
}
}
async function loadLabels() {
try {
const res = await fetch("/labels.json");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
allLabels = Array.isArray(data.labels) ? data.labels : [];
selectedConsumables = new Set(allLabels.slice(0, 5));
renderConsumableChips();
syncJsonFromChips();
} catch (e) {
allLabels = [];
selectedConsumables = new Set();
renderConsumableChips();
$("labels-meta").textContent = "标签加载失败";
console.warn(e);
}
}
function setActiveMode(mode) {
activeMode = mode;
document.querySelectorAll(".mode-card").forEach((card) => {
card.classList.toggle("active", card.dataset.mode === mode);
});
document.querySelectorAll(".mode-panel").forEach((panel) => {
panel.classList.toggle("active", panel.dataset.mode === mode);
});
const voice = $("voice-callout");
if (voice) voice.classList.toggle("hidden", mode === "offline-batch");
const btnStart = $("btn-start");
const btnEnd = $("btn-end");
if (btnStart) {
btnStart.textContent =
mode === "offline-batch"
? "上传并处理"
: mode === "live-simulated"
? "开始模拟开录"
: "开始手术";
}
if (btnEnd) btnEnd.disabled = mode === "offline-batch";
$("pill-mode").textContent =
mode === "live-rtsp"
? "链路 1 · 真摄像头"
: mode === "live-simulated"
? "链路 2 · 模拟实时"
: "链路 3 · 离线精确";
refreshRecordingModesStatus();
}
async function refreshHealth() {
const pill = $("pill-health");
try {
const { res } = await apiJson("GET", "/health");
if (pill) {
pill.textContent = res.ok ? "API 正常" : "API 异常 " + res.status;
pill.className = "pill " + (res.ok ? "ok" : "err");
}
} catch {
if (pill) {
pill.textContent = "API 不可达";
pill.className = "pill err";
}
}
}
async function refreshRecordingModesStatus() {
const pill = $("pill-demo-modes");
if (activeMode === "live-rtsp") {
if (pill) {
pill.textContent = "链路 2/3 未检测";
pill.className = "pill";
}
return;
}
const url = baseUrl() + "/internal/demo/recording-modes-status";
try {
const res = await fetch(url);
const data = await res.json();
addLog("GET", url, res.status, data, { error: !res.ok });
if (!pill) return;
const on = data.demo_recording_modes_enabled === true || data.orchestrator_enabled === true;
const fset = data.or_site_config_json_file_set === true;
if (!res.ok) {
pill.textContent = "状态拉取失败";
pill.className = "pill err";
return;
}
if (on && fset) {
pill.textContent = "链路 2/3 已就绪";
pill.className = "pill ok";
} else if (on) {
pill.textContent = "缺 OR_SITE_CONFIG";
pill.className = "pill warn";
} else {
pill.textContent = "DEMO 未开启";
pill.className = "pill err";
}
} catch (e) {
if (pill) {
pill.textContent = "状态失败";
pill.className = "pill err";
}
}
}
function hideVideoBatchVisualization() {
const wrap = $("video-batch-vis");
const player = $("video-batch-vis-player");
if (wrap) wrap.classList.add("hidden");
if (player) {
player.removeAttribute("src");
player.load();
}
}
function showVideoBatchVisualization(sid, urlPath, doctorDisplay) {
const wrap = $("video-batch-vis");
const player = $("video-batch-vis-player");
const hint = $("video-batch-vis-hint");
if (!wrap || !player) return;
const path = urlPath || `/internal/demo/offline-batch/${sid}/visualization`;
const src = baseUrl() + path + "?t=" + Date.now();
wrap.classList.remove("hidden");
const docEl = $("video-batch-doctor-info");
const text = (doctorDisplay || lastVideoBatchDoctorDisplay || "").trim();
if (docEl) {
docEl.textContent = text ? "识别医生:" + text : "";
docEl.style.display = text ? "block" : "none";
}
player.onerror = () => {
if (hint) hint.textContent = "视频加载失败,请稍后重试或新标签页打开链接。";
};
player.onloadeddata = () => {
if (hint) hint.textContent = "标注视频已加载";
};
player.src = src;
player.load();
if (hint) hint.textContent = "正在加载…";
}
function getDebugStreamCount() {
const sel = $("debug-stream-count");
const v = sel ? parseInt(sel.value, 10) : 1;
return v >= 1 && v <= 4 ? v : 1;
}
function applyDebugStreamVisibility() {
const n = getDebugStreamCount();
for (let i = 1; i <= 4; i++) {
const el = $("sim-stream-" + i);
if (el) el.classList.toggle("hidden", i > n);
}
}
function assignFileToInput(inputEl, file) {
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
}
function setSlotFile(slot, file, source) {
const vfile = $("sim-vfile-" + slot);
const hint = $("sim-hint-" + slot);
const fname = $("sim-fname-" + slot);
if (vfile && file) assignFileToInput(vfile, file);
if (fname) fname.textContent = file ? file.name : "";
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
}
function setOfflineFile(file, source) {
const vfile = $("offline-vfile");
const fname = $("offline-fname");
const hint = $("offline-hint");
if (vfile && file) assignFileToInput(vfile, file);
if (fname) fname.textContent = file ? file.name : "点击或拖放 MP4 到此处";
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
}
function bindDropZone(zoneEl, onFile) {
if (!zoneEl) return;
zoneEl.addEventListener("dragover", (ev) => {
ev.preventDefault();
zoneEl.classList.add("drag-over");
});
zoneEl.addEventListener("dragleave", () => zoneEl.classList.remove("drag-over"));
zoneEl.addEventListener("drop", (ev) => {
ev.preventDefault();
zoneEl.classList.remove("drag-over");
const f = ev.dataTransfer?.files?.[0];
if (f && looksLikeVideo(f)) onFile(f, "拖放");
else showBanner("请拖入视频文件MP4 等)", "warn");
});
}
function looksLikeVideo(f) {
return (
/^video\//.test(f.type || "") ||
/\.(mp4|mov|mkv|avi|webm|m4v)$/i.test(f.name || "")
);
}
function pickMediaRecorderMime() {
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return "";
for (const m of [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4",
]) {
if (MediaRecorder.isTypeSupported(m)) return m;
}
return "";
}
async function toggleWebcamSlot(slot) {
const st = webcamSlotState[slot] || (webcamSlotState[slot] = {});
const btn = $("sim-webcam-" + slot);
const hint = $("sim-hint-" + slot);
if (!st.recording) {
if (!navigator.mediaDevices?.getUserMedia) {
showBanner("需要 HTTPS 或 localhost 才能使用摄像头", "warn");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user" },
audio: true,
});
const mime = pickMediaRecorderMime();
st.chunks = [];
const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
rec.ondataavailable = (ev) => {
if (ev.data?.size) st.chunks.push(ev.data);
};
rec.start(250);
st.recording = true;
st.stream = stream;
st.recorder = rec;
if (btn) {
btn.textContent = "停止录制";
btn.classList.add("warn");
}
if (hint) hint.textContent = "录制中…";
} catch (e) {
showBanner("无法打开摄像头:" + (e.message || String(e)), "err");
}
return;
}
const rec = st.recorder;
const stream = st.stream;
st.recording = false;
st.recorder = null;
st.stream = null;
if (btn) {
btn.textContent = "摄像头";
btn.classList.remove("warn");
}
await new Promise((resolve) => {
rec.addEventListener("stop", resolve, { once: true });
rec.stop();
});
stream?.getTracks().forEach((t) => t.stop());
const blob = new Blob(st.chunks, { type: rec.mimeType || "video/webm" });
st.chunks = [];
if (!blob.size) {
if (hint) hint.textContent = "未录到数据";
return;
}
const ext = (rec.mimeType || "").includes("mp4") ? ".mp4" : ".webm";
const file = new File([blob], "webcam-" + slot + "-" + Date.now() + ext, { type: blob.type });
setSlotFile(slot, file, "摄像头");
}
async function handleStart() {
const sid = ensureSurgeryId();
if (!sid) return;
let candidateConsumables;
try {
candidateConsumables = getSelectedConsumables();
} catch (e) {
showBanner(e.message || String(e), "err");
return;
}
const btn = $("btn-start");
setLoading(btn, true);
showBanner("请求进行中…", "info");
try {
const mode = getRunMode();
if (mode === "offline-batch") {
const f = $("offline-vfile")?.files?.[0];
if (!f) {
showBanner("请先选择完整 MP4 文件", "err");
return;
}
const fd = new FormData();
fd.append("surgery_id", sid);
fd.append("video1", f, f.name);
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
fd.append(
"include_visualization",
$("offline-batch-include-vis")?.checked ? "true" : "false",
);
const { res, body } = await apiOfflineBatch(fd);
if (!res.ok) {
showBanner("离线处理失败:" + formatDetail(body), "err");
return;
}
lastVideoBatchDoctorDisplay = body?.doctor_display || "";
if ($("offline-batch-include-vis")?.checked && body?.visualization_url) {
showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay);
} else hideVideoBatchVisualization();
showBanner("离线处理完成,正在查询结果…", "ok");
await handleResult();
return;
}
if (mode === "live-simulated") {
const n = getDebugStreamCount();
const files = [];
for (let i = 1; i <= n; i++) {
files.push($("sim-vfile-" + i)?.files?.[0]);
}
if (files.length !== n || !files.every(Boolean)) {
showBanner("请为路 1…" + n + " 全部选择视频文件", "err");
return;
}
const fd = new FormData();
fd.append("surgery_id", sid);
fd.append("video1", files[0], files[0].name);
if (n >= 2) fd.append("video2", files[1], files[1].name);
if (n >= 3) fd.append("video3", files[2], files[2].name);
if (n >= 4) fd.append("video4", files[3], files[3].name);
for (let i = 1; i <= 4; i++) {
fd.append(
"camera_" + i,
($("adv-cam-" + i)?.value || DEF_CAMS[i - 1]).trim() || DEF_CAMS[i - 1],
);
fd.append(
"rtsp_path_" + i,
($("adv-rpath-" + i)?.value || DEF_RP[i - 1]).trim() || DEF_RP[i - 1],
);
}
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
const { res, body } = await apiMultipart(
"/internal/demo/simulated-start",
fd,
"simulated-start",
);
if (!res.ok) {
showBanner("模拟开录失败:" + formatDetail(body), "err");
return;
}
showBanner("模拟开录已接受,请打开语音终端", "ok");
return;
}
const camera_ids = $("camera-ids")
.value.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!camera_ids.length) {
showBanner("请填写至少一个 camera_id", "err");
return;
}
const { res, body } = await apiJson("POST", "/client/surgeries/start", {
surgery_id: sid,
camera_ids,
candidate_consumables: candidateConsumables,
});
if (!res.ok) {
showBanner("开录失败:" + formatDetail(body), "err");
return;
}
showBanner("开录已接受,请打开语音终端", "ok");
} catch (e) {
showBanner("网络错误:" + String(e), "err");
} finally {
setLoading(btn, false);
}
}
async function handleEnd() {
const sid = ensureSurgeryId();
if (!sid) return;
const btn = $("btn-end");
setLoading(btn, true);
try {
const { res, body } = await apiJson("POST", "/client/surgeries/end", { surgery_id: sid });
showBanner(res.ok ? "手术已结束" : "结束失败:" + formatDetail(body), res.ok ? "ok" : "err");
} finally {
setLoading(btn, false);
}
}
async function handleResult() {
const sid = ensureSurgeryId();
if (!sid) return;
if (getRunMode() === "offline-batch" && $("offline-batch-include-vis")?.checked) {
showVideoBatchVisualization(sid, null);
} else if (getRunMode() !== "offline-batch") {
hideVideoBatchVisualization();
}
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`);
const target = $("result-render");
if (!target) return;
target.innerHTML = "";
if (!res.ok || !body || typeof body !== "object") {
showBanner(res.ok ? "无结果数据" : "查询失败:" + formatDetail(body), "err");
return;
}
const { details = [], summary = [] } = body;
const renderTable = (title, rows, cols) => {
const h = document.createElement("h3");
h.textContent = title;
target.appendChild(h);
const t = document.createElement("table");
t.innerHTML =
"<thead><tr>" + cols.map((c) => `<th>${c.label}</th>`).join("") + "</tr></thead>";
const tbody = document.createElement("tbody");
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${cols.length}">(空)</td></tr>`;
} else {
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = cols.map((c) => `<td>${row[c.key] ?? ""}</td>`).join("");
tbody.appendChild(tr);
});
}
t.appendChild(tbody);
target.appendChild(t);
};
renderTable("消耗明细", details, [
{ key: "timestamp", label: "时间" },
{ key: "item_name", label: "耗材" },
{ key: "qty", label: "数量" },
{ key: "doctor_id", label: "医生" },
]);
renderTable("汇总", summary, [
{ key: "item_name", label: "耗材" },
{ key: "total_quantity", label: "合计" },
]);
showBanner("结果已加载", "ok");
}
function initModeCards() {
document.querySelectorAll(".mode-card").forEach((card) => {
card.addEventListener("click", () => setActiveMode(card.dataset.mode));
});
setActiveMode("live-rtsp");
}
function initSimulatedUploads() {
$("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility);
applyDebugStreamVisibility();
for (let i = 1; i <= 4; i++) {
const pick = $("sim-pick-" + i);
const vfile = $("sim-vfile-" + i);
const zone = $("sim-zone-" + i);
if (pick && vfile) {
pick.onclick = () => vfile.click();
vfile.onchange = () => {
const f = vfile.files?.[0];
if (f) setSlotFile(i, f, "选择");
};
}
bindDropZone(zone, (f) => setSlotFile(i, f, "拖放"));
zone?.addEventListener("click", (ev) => {
if (ev.target.closest("button")) return;
vfile?.click();
});
$("sim-webcam-" + i)?.addEventListener("click", () => toggleWebcamSlot(i));
}
}
function initOfflineUpload() {
const zone = $("offline-zone");
const vfile = $("offline-vfile");
$("offline-pick")?.addEventListener("click", () => vfile?.click());
vfile?.addEventListener("change", () => {
const f = vfile.files?.[0];
if (f) setOfflineFile(f, "选择");
});
bindDropZone(zone, (f) => setOfflineFile(f, "拖放"));
zone?.addEventListener("click", (ev) => {
if (ev.target.closest("button")) return;
vfile?.click();
});
}
function initConsumables() {
$("chip-search")?.addEventListener("input", renderConsumableChips);
$("btn-chips-all")?.addEventListener("click", () => {
allLabels.forEach((l) => selectedConsumables.add(l));
renderConsumableChips();
syncJsonFromChips();
});
$("btn-chips-clear")?.addEventListener("click", () => {
selectedConsumables.clear();
renderConsumableChips();
syncJsonFromChips();
});
$("candidate-consumables-json")?.addEventListener("change", syncChipsFromJson);
$("candidate-consumables-json")?.addEventListener("blur", syncChipsFromJson);
}
function init() {
applyLanDefaultApiBase();
initModeCards();
initSimulatedUploads();
initOfflineUpload();
initConsumables();
loadLabels();
$("btn-start")?.addEventListener("click", handleStart);
$("btn-end")?.addEventListener("click", handleEnd);
$("btn-result")?.addEventListener("click", handleResult);
$("btn-refresh-status")?.addEventListener("click", () => {
refreshHealth();
refreshRecordingModesStatus();
});
$("btn-clear-log")?.addEventListener("click", () => {
if (logEl) logEl.innerHTML = "";
});
$("base-url")?.addEventListener("change", refreshRecordingModesStatus);
refreshHealth();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();