From b03eccde5051aeb576799f1b8af177babd653b74 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 13:09:55 +0900 Subject: [PATCH] feat: protect unsaved camera work --- README.md | 3 + apps/acoustic-camera/README.md | 8 +++ apps/acoustic-camera/index.html | 10 ++- apps/acoustic-camera/main.js | 105 ++++++++++++++++++++++++++-- apps/acoustic-camera/model.js | 1 + apps/acoustic-camera/styles.css | 6 +- docs/data-contracts.md | 2 + docs/execution-plan.md | 15 ++-- docs/index.html | 13 +++- tests/integration/camera-ui.test.js | 46 ++++++++++++ 10 files changed, 192 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 475d23f..ee2efd8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수 있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다. +Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수 +있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다. + ## Scene JSON과 로컬 assets Scene JSON에는 원본 이미지나 오디오 byte가 들어가지 않습니다. 대신 asset id, 종류, 파일명과 diff --git a/apps/acoustic-camera/README.md b/apps/acoustic-camera/README.md index cb72d96..7c513cc 100644 --- a/apps/acoustic-camera/README.md +++ b/apps/acoustic-camera/README.md @@ -65,6 +65,14 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 * - rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에 backend, timing과 평가 요약이 남습니다. - 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다. +- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각 + **JSON 저장** 버튼으로 보존할 수 있습니다. +- **전체 기록 JSON**은 세션에 남은 모든 배열 비교 요약을 + `micarrsim.comparison-history` 문서로 저장합니다. + +미저장 Scene·Experiment·Result·비교 기록은 우측 저장 상태에 표시됩니다. 직전 Result를 저장하지 +않고 새 실행을 시작하면 교체 여부를 확인하며, 미저장 항목이나 실행 중 계산이 있는 상태에서 +페이지를 닫으면 브라우저가 한 번 더 확인합니다. ## Result JSON diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index f6dad7e..6445aad 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -61,7 +61,7 @@
-

Same-scene comparison

배열 비교 기록

+

Same-scene comparison

배열 비교 기록

비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.

@@ -124,7 +124,13 @@
-
+
+
+ + +
+

저장하지 않은 변경 없음

+
diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 7224d07..f2ef958 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -82,7 +82,10 @@ const elements = { postprocess: byId("postprocess"), addBandButton: byId("add-band-button"), bandList: byId("band-list"), + exportSceneButton: byId("export-scene-button"), exportExperimentButton: byId("export-experiment-button"), + exportHistoryButton: byId("export-history-button"), + outputSaveState: byId("output-save-state"), validationCard: document.querySelector(".validation-card"), validationTitle: byId("validation-title"), validationMessage: byId("validation-message"), @@ -101,6 +104,9 @@ const state = { active: null, pendingInputOperations: 0, importGenerations: { array: 0, scene: 0, experiment: 0 }, + sceneDirty: false, + experimentDirty: false, + resultDirty: false, }; const sceneVisualization = createSceneVisualization(elements.sceneCanvas); @@ -141,6 +147,29 @@ function setRunStatus(message) { elements.runStatus.textContent = message; } +function unsavedWorkLabels() { + const labels = []; + if (state.sceneDirty) labels.push("Scene"); + if (state.experimentDirty) labels.push("Experiment"); + if (state.resultDirty) labels.push("Result"); + if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록"); + return labels; +} + +function syncUnsavedState() { + const labels = unsavedWorkLabels(); + document.documentElement.dataset.unsavedWork = labels.join(","); + elements.outputSaveState.textContent = labels.length > 0 + ? `미저장 · ${labels.join(" · ")}` + : "저장하지 않은 변경 없음"; + elements.outputSaveState.classList.toggle("is-dirty", labels.length > 0); + elements.exportHistoryButton.disabled = state.history.length === 0; +} + +function hasUnsavedWork() { + return Boolean(state.active) || unsavedWorkLabels().length > 0; +} + function setTransientError(error, prefix = "오류") { const message = error instanceof Error ? error.message : String(error); setRunStatus(`${prefix} · ${message}`); @@ -194,6 +223,9 @@ function readFiniteInput(element) { } function markChanged(options = {}) { + const dirty = options.dirty ?? "experiment"; + if (dirty === "scene" || dirty === "both") state.sceneDirty = true; + if (dirty === "experiment" || dirty === "both") state.experimentDirty = true; state.revision += 1; if (state.lastRun) state.lastRun.stale = true; if (["error", "canceled"].includes(document.documentElement.dataset.runState)) { @@ -207,6 +239,7 @@ function markChanged(options = {}) { renderSceneVisualization(); renderResult(); renderHistory(); + syncUnsavedState(); scheduleValidation(); } @@ -496,6 +529,7 @@ function renderResult() { function renderHistory() { elements.historyList.replaceChildren(); + elements.exportHistoryButton.disabled = state.history.length === 0; const signature = comparisonSignature(); const items = state.history.filter((item) => item.comparisonSignature === signature); if (items.length === 0) { @@ -718,6 +752,17 @@ async function runSimulation() { await validateCurrent(); return; } + if ( + state.resultDirty && + !window.confirm("직전 Result를 아직 저장하지 않았습니다. 새 실행으로 교체할까요?") + ) { + setRunStatus("직전 Result를 먼저 저장한 뒤 다시 실행할 수 있습니다."); + return; + } + if (state.resultDirty) { + state.resultDirty = false; + syncUnsavedState(); + } const snapshot = { arrayDefinition: structuredClone(state.arrayDefinition), scene: structuredClone(state.scene), @@ -818,8 +863,11 @@ async function runSimulation() { inputHash, }); historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment); + historyItem.comparisonSaved = false; state.history.unshift(historyItem); state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS); + state.resultDirty = true; + syncUnsavedState(); document.documentElement.dataset.runState = "done"; document.documentElement.dataset.runBackend = run.backend; document.documentElement.dataset.resultBandCount = String(run.bands.length); @@ -921,12 +969,18 @@ async function importDocument(kind, input) { state.scene = value; state.experiment.sceneId = value.id; alignExperimentToScene(state.experiment, value); + state.sceneDirty = false; assetRuntime.reset(); } else { state.experiment = value; + state.experimentDirty = false; } state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id)); - markChanged({ syncControls: true, renderBands: true }); + markChanged({ + syncControls: true, + renderBands: true, + dirty: kind === "experiment" ? "none" : "experiment", + }); renderAssets(); setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`); } catch (error) { @@ -949,19 +1003,19 @@ function installDocumentInputListeners() { const value = readFiniteInput(elements.sceneDistance); if (value === null) return syncControls(); shiftSceneDistance(state.scene, state.experiment, value); - markChanged(); + markChanged({ dirty: "both" }); }); elements.speedOfSound.addEventListener("change", () => { const value = readFiniteInput(elements.speedOfSound); if (value === null) return syncControls(); state.scene.speedOfSound = value; - markChanged(); + markChanged({ dirty: "scene" }); }); elements.sceneSeed.addEventListener("change", () => { const value = readFiniteInput(elements.sceneSeed); if (value === null) return syncControls(); state.scene.seed = value; - markChanged(); + markChanged({ dirty: "scene" }); }); elements.backend.addEventListener("change", () => { state.experiment.beamforming.backend = elements.backend.value; @@ -1103,6 +1157,20 @@ function installActionListeners() { }); elements.overlayOpacity.addEventListener("input", renderSceneVisualization); elements.showTruth.addEventListener("change", renderSceneVisualization); + elements.exportSceneButton.addEventListener("click", async () => { + const issues = await validateScene(state.scene); + if (issues.length > 0) { + setTransientError( + new Error(issues.slice(0, 4).map(formatIssue).join(" · ")), + "Scene 저장 실패", + ); + return; + } + downloadJson(state.scene, `${safeFileStem(state.scene.id)}.scene.json`); + state.sceneDirty = false; + syncUnsavedState(); + setRunStatus("Scene JSON을 저장했습니다."); + }); elements.exportExperimentButton.addEventListener("click", async () => { const issues = await validateExperiment(state.experiment, { arrayDefinition: state.arrayDefinition, @@ -1116,6 +1184,8 @@ function installActionListeners() { return; } downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`); + state.experimentDirty = false; + syncUnsavedState(); setRunStatus("Experiment JSON을 저장했습니다."); }); elements.exportResultButton.addEventListener("click", () => { @@ -1124,14 +1194,38 @@ function installActionListeners() { state.lastRun.resultDocument, `${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`, ); + state.resultDirty = false; + syncUnsavedState(); setRunStatus("검증된 Result JSON을 저장했습니다."); }); + elements.exportHistoryButton.addEventListener("click", () => { + if (state.history.length === 0) return; + const items = state.history.map(({ comparisonSignature, comparisonSaved, ...item }) => item); + downloadJson( + { + schemaVersion: "1.0.0", + kind: "micarrsim.comparison-history", + createdAt: new Date().toISOString(), + items, + }, + "micarrsim.comparison-history.json", + ); + for (const item of state.history) item.comparisonSaved = true; + syncUnsavedState(); + setRunStatus("전체 배열 비교 기록 JSON을 저장했습니다."); + }); elements.clearHistoryButton.addEventListener("click", () => { const signature = comparisonSignature(); state.history = state.history.filter((item) => item.comparisonSignature !== signature); renderHistory(); + syncUnsavedState(); }); - window.addEventListener("beforeunload", () => { + window.addEventListener("beforeunload", (event) => { + if (window.top !== window || !hasUnsavedWork()) return; + event.preventDefault(); + event.returnValue = ""; + }); + window.addEventListener("unload", () => { cancelSimulation(); sceneVisualization.destroy(); arrayVisualization.destroy(); @@ -1164,6 +1258,7 @@ async function initialize() { renderResult(); renderHistory(); renderSceneVisualization(); + syncUnsavedState(); await validateCurrent(); setEditableControlsDisabled(false); renderBandEditors(); diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index ef3bde1..2102db4 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -224,6 +224,7 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run, const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0]; return { id: `${Date.now()}-${inputHash.slice(0, 8)}`, + createdAt: new Date().toISOString(), arrayId: arrayDefinition.id, arrayName: arrayDefinition.name, sceneId: scene.id, diff --git a/apps/acoustic-camera/styles.css b/apps/acoustic-camera/styles.css index c84f3e6..0141d57 100644 --- a/apps/acoustic-camera/styles.css +++ b/apps/acoustic-camera/styles.css @@ -46,6 +46,7 @@ .metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; } .text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; } .history-list { display: grid; gap: 8px; margin-top: 14px; } +.history-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; } .history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; } .history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; } .history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -78,6 +79,9 @@ .asset-status.is-error span { color: var(--danger); } .asset-empty { color: var(--muted); font-size: .68rem; } .builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; } +.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.output-save-state { margin: 9px 0 0; color: var(--muted); font-size: .65rem; text-align: center; } +.output-save-state.is-dirty { color: var(--amber); } .field { display: grid; min-width: 0; gap: 6px; } .field > span { color: var(--muted); font-size: .67rem; font-weight: 650; } .field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; } @@ -121,7 +125,7 @@ .history-card strong { grid-column: 1 / -1; } } @media (max-width: 430px) { - .field-grid, .band-editor { grid-template-columns: 1fr; } + .field-grid, .band-editor, .output-actions { grid-template-columns: 1fr; } .band-editor__remove { min-height: 34px; } .metric-grid { grid-template-columns: 1fr 1fr; } } diff --git a/docs/data-contracts.md b/docs/data-contracts.md index f79abb5..b21d4f5 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -10,6 +10,8 @@ micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증 - `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest - `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정 - `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보 +- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema + 검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다. scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않는다. 합성 tone/noise는 seed가 포함된 recipe로 재생성하고, 녹음 파일과 이미지는 `id`, `kind`, `fileName`, `mimeType`, diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 7b796aa..7eab0fa 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -80,8 +80,9 @@ DAS는 STFT 후 각 scan point와 마이크 사이 거리로 steering phase를 ## 5. UI 계획 `acoustic-camera`는 폭 600px 이상인 좌측 scene canvas에 기기 이미지, ground-truth hint, -주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 확대·회전 가능한 평면도로 -표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. +주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 좌표와 함께 정적 평면도로 +표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. 확대·회전 상호작용은 +향후 필요성 검증 뒤 추가한다. 필수 입력은 음원 수와 위치, AC 거리, 다중 주파수 구간과 색, ambient 파일, scan grid 크기, 후처리 선택이다. 여기에 sample rate, 음속/온도, STFT window·hop, scan 영역, dynamic range, @@ -114,7 +115,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로 ### 단계 0 — 기반과 계약 · 완료 -- workspace, formatter, linter, unit test, WebGPU feature detection 구성 +- 무설치 ES module workspace, browser test runner, WebGPU feature detection 구성 - 네 가지 JSON Schema와 최소 예제 작성 - 좌표계·단위·오디오 normalization 결정 기록 - synthetic single-tone scene과 192채널 기준 배열 작성 @@ -133,7 +134,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로 - 이미지와 음원 파일 불러오기, 위치·시간·gain 편집, ambient mix와 미리 듣기 - deterministic seed, clipping 경고, Scene JSON과 mono Preview WAV 내보내기 -- 데이터셋 adapter 인터페이스와 synthetic adapter 구현 +- synthetic tone/noise fixture와 로컬 asset manifest 경계 구현 완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다. @@ -166,8 +167,10 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로 - WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다. - 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다. -- 브라우저 메모리: 전체 시간×192 신호를 항상 보관하지 않고 chunk/STFT frame 단위로 처리한다. -- 데이터셋 부적합/라이선스: adapter 경계를 유지하고 synthetic fixture를 기본 CI 입력으로 쓴다. +- 브라우저 메모리: 현재 전체 192채널 signal buffer에 512 MiB 한도를 적용한다. 장시간 입력은 + 향후 chunk/STFT streaming backend로 확장한다. +- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로 + 쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다. - 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다. - post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다. diff --git a/docs/index.html b/docs/index.html index d3cb8ad..4ab3894 100644 --- a/docs/index.html +++ b/docs/index.html @@ -44,9 +44,10 @@
  • 완료되면 Result JSON을 눌러 다운로드 폴더에 저장합니다.
  • @@ -123,12 +124,18 @@
    evaluation
    검출 threshold, 반경, tolerance 등 지표 계산 조건
    timing / environment
    backend, grid·scan 범위, 장치 정보와 구간별 시간
    inputHash
    Array·Scene·Experiment 입력을 묶은 SHA-256 재현성 식별자
    +
    comparison-history
    세션의 배열별 시간·위치 오차·PSR 요약을 한 번에 저장
    +

    + Camera에서 바꾼 Scene과 Experiment는 각각 저장할 수 있습니다. 완료 Result와 배열 비교 + 기록도 별도 JSON으로 저장하세요. 저장하지 않은 항목이나 실행 중인 계산이 있으면 + 페이지를 닫을 때 브라우저가 확인합니다. +

    diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index a67eedf..1b83526 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -44,6 +44,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 const objectUrls = []; const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click; const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL); + const originalConfirm = windowObject.confirm.bind(windowObject); windowObject.HTMLAnchorElement.prototype.click = function captureDownload() { downloads.push({ fileName: this.download, url: this.href }); }; @@ -73,6 +74,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 "8×8 CPU 입력 검증", ); assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset"); + assert( + documentObject.documentElement.dataset.unsavedWork.includes("Experiment"), + "edited Experiment is marked unsaved", + ); documentObject.querySelector("#run-button").click(); await waitFor( @@ -85,6 +90,20 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered"); assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible"); assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered"); + assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved"); + assert( + documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"), + "comparison summary is unsaved", + ); + let replacementConfirmCount = 0; + windowObject.confirm = () => { + replacementConfirmCount += 1; + return false; + }; + documentObject.querySelector("#run-button").click(); + await waitFor(() => replacementConfirmCount === 1, "unsaved Result replacement confirmation"); + assertEqual(documentObject.documentElement.dataset.runState, "done", "declined replacement keeps result"); + windowObject.confirm = originalConfirm; await nextPaint(windowObject); assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas"); @@ -94,6 +113,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 "Result JSON download", ); const resultDocument = await fetch(resultDownload.url).then((response) => response.json()); + assert( + !documentObject.documentElement.dataset.unsavedWork.includes("Result"), + "Result export clears Result dirty state", + ); const [scene, experiment] = await Promise.all([ loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), @@ -156,7 +179,30 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 const historyText = documentObject.querySelector("#history-list").textContent; assert(historyText.includes("rectangular baseline"), "baseline history retained"); assert(historyText.includes("Fermat spiral"), "comparison array history added"); + + documentObject.querySelector("#export-history-button").click(); + const historyDownload = await waitFor( + () => downloads.find((download) => download.fileName.endsWith(".comparison-history.json")), + "comparison history download", + ); + const historyDocument = await fetch(historyDownload.url).then((response) => response.json()); + assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind"); + assertEqual(historyDocument.items.length, 2, "both comparison summaries exported"); + assert( + !documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"), + "history export clears comparison dirty state", + ); + + documentObject.querySelector("#export-result-button").click(); + documentObject.querySelector("#export-experiment-button").click(); + await waitFor( + () => !documentObject.documentElement.dataset.unsavedWork.includes("Result") && + !documentObject.documentElement.dataset.unsavedWork.includes("Experiment"), + "final Camera documents saved", + ); + assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved"); } finally { + windowObject.confirm = originalConfirm; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.URL.revokeObjectURL = originalRevoke; for (const url of objectUrls) originalRevoke(url);