fix: rebind imported experiments to active inputs

This commit is contained in:
2026-08-29 08:35:39 +09:00
parent 62971e07ec
commit 447f77dfa7
7 changed files with 130 additions and 47 deletions
+6 -6
View File
@@ -113,8 +113,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
### 3. Acoustic Camera에서 실행하기 ### 3. Acoustic Camera에서 실행하기
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다. 1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment를 먼저 2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment는 어느
열어도 모든 분석 대역이 유지됩니다. 시점에 열어도 모든 분석 대역을 유지하고 현재 Scene·Array에 자동으로 연결됩니다.
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다. 3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다. 4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다.
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다. 5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
@@ -123,10 +123,10 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다. 7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
8. 완료 후 **Result JSON**을 저장합니다. 8. 완료 후 **Result JSON**을 저장합니다.
Experiment를 먼저 불러오면 문서와 모든 `frequencyBands`를 즉시 편집할 수 있습니다. 현재 Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를
Scene·Array와 `sceneId`·`arrayId`가 다른 동안에는 검증 카드에 연결 대기를 표시하고 실행만 현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은
비활성화합니다. 참조하는 Scene과 Array를 이어서 불러오면 실행할 수 있습니다. Scene을 다시 보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행하므로 Nyquist·scan 등
불러오면 이전 asset 연결은 해제됩니다. 실제 오류는 그대로 거부됩니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에 Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와 유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
+6 -3
View File
@@ -27,7 +27,7 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g
## 사용자 입력 불러오기 ## 사용자 입력 불러오기
문서는 다음 순서로 불러오는 것이 안전합니다. 문서는 다음 순서로 불러오면 asset 연결까지 한 번에 확인하기 쉽습니다.
1. **Scene**: `.scene.json` 1. **Scene**: `.scene.json`
2. **Array**: 선택 사항인 `.array.json`; 또는 **비교 배열** preset 사용 2. **Array**: 선택 사항인 `.array.json`; 또는 **비교 배열** preset 사용
@@ -35,8 +35,11 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g
4. **Assets**: Scene이 참조하는 원본 이미지와 오디오 4. **Assets**: Scene이 참조하는 원본 이미지와 오디오
Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 맞추고 기존 asset 연결을 Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 맞추고 기존 asset 연결을
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 현재 해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느
Scene과 Array를 참조해야 하므로 마지막에 불러오세요. 시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist·scan 같은 실제 관계 오류는
그대로 거부됩니다.
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면 Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다. Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
+1 -1
View File
@@ -40,7 +40,7 @@
<div class="workspace-tools input-file-grid" aria-label="입력 파일"> <div class="workspace-tools input-file-grid" aria-label="입력 파일">
<label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label> <label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label>
<label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label> <label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label>
<label class="file-button file-choice" title="Experiment를 먼저 열 수 있습니다. 참조 ID가 다른 동안에는 실행만 비활성화됩니다."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정 · 선로드 가능</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label> <label class="file-button file-choice" title="Experiment를 먼저 열 수 있습니다. sceneId와 arrayId는 현재 열린 Scene·Array에 자동으로 연결됩니다."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정 · ID 자동 연결</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label>
<label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label> <label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
</div> </div>
+25 -11
View File
@@ -1568,16 +1568,30 @@ async function importDocument(kind, input) {
try { try {
const value = await readJsonFile(file); const value = await readJsonFile(file);
if (generation !== state.importGenerations[kind]) return; if (generation !== state.importGenerations[kind]) return;
let importedExperimentReferences = null;
let issues; let issues;
if (kind === "array") { if (kind === "array") {
issues = await validateArrayDefinition(value); issues = await validateArrayDefinition(value);
} else if (kind === "scene") { } else if (kind === "scene") {
issues = await validateScene(value); issues = await validateScene(value);
} else { } else {
// An Experiment is a useful editable document even before its referenced // Validate the imported document independently before changing any
// Scene or Array has been loaded. Validate its own contract here, then let // references. Then bind it to the open workspace and run the relationship
// validateCurrent() keep Run disabled until the document IDs line up. // checks as well, so import convenience does not weaken the contract.
issues = await validateExperiment(value); issues = await validateExperiment(value);
if (generation !== state.importGenerations[kind]) return;
if (issues.length === 0) {
importedExperimentReferences = {
sceneId: value.sceneId,
arrayId: value.arrayId,
};
value.sceneId = state.scene.id;
value.arrayId = state.arrayDefinition.id;
issues = await validateExperiment(value, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
});
}
} }
if (generation !== state.importGenerations[kind]) return; if (generation !== state.importGenerations[kind]) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
@@ -1606,14 +1620,14 @@ async function importDocument(kind, input) {
: kind === "experiment" : kind === "experiment"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개` ? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
: ""; : "";
const pendingReferences = kind === "experiment" const referencesChanged = kind === "experiment" && (
? [ importedExperimentReferences?.sceneId !== state.scene.id ||
value.sceneId !== state.scene?.id ? `Scene ${value.sceneId}` : null, importedExperimentReferences?.arrayId !== state.arrayDefinition.id
value.arrayId !== state.arrayDefinition?.id ? `Array ${value.arrayId}` : null, );
].filter(Boolean) const referenceStatus = kind === "experiment"
: []; ? referencesChanged
const referenceStatus = pendingReferences.length > 0 ? ` · 현재 Scene ${state.scene.id} · Array ${state.arrayDefinition.id}로 재연결`
? ` · ${pendingReferences.join(" · ")} 연결 대기` : " · 현재 Scene · Array 연결 확인"
: ""; : "";
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`); setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`);
} catch (error) { } catch (error) {
+5
View File
@@ -189,6 +189,11 @@ SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 c
현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의 현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다. 실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와
`arrayId`를 현재 열린 Scene·Array의 id로 연결하고, 그 context에서 관계 검증을 다시 수행한다.
이 가져오기 편의 동작은 계약을 완화하지 않는다. 내보내기와 실행에 쓰는 Experiment도 아래 관계를
모두 만족해야 한다.
- 중복 id와 array/scene/experiment/result 참조 - 중복 id와 array/scene/experiment/result 참조
- asset 종류와 안전한 상대 경로 - asset 종류와 안전한 상대 경로
- Nyquist 및 시간·scan 범위 - Nyquist 및 시간·scan 범위
+6 -6
View File
@@ -109,10 +109,10 @@
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li> <li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
<li> <li>
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로 Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
가져오는 것을 권장합니다. Experiment를 먼저 열어도 분석 대역은 보존되며, 참조 가져오는 것을 권장합니다. Experiment는 어느 시점에 열어도 분석 대역을 보존하고
Scene·Array가 연결될 때까지 실행만 비활성화됩니다. Array Builder에서 저장한 파일이 <code>sceneId</code>와 <code>arrayId</code>를 현재 열린 입력에 자동으로 연결합니다.
있다면 Array 단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 재연결 뒤 관계 검증도 다시 수행됩니다. Array Builder에서 저장한 파일이 있다면 Array
둡니다. 단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
</li> </li>
<li> <li>
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다. Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
@@ -268,8 +268,8 @@
<p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p> <p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p>
</details> </details>
<details> <details>
<summary>Experiment를 가져온 뒤 실행할 수 없습니다.</summary> <summary>Experiment를 가져왔는데 검증 오류가 남습니다.</summary>
<p>Experiment부터 가져와도 <code>frequencyBands</code>는 모두 표시됩니다. 검증 카드에 연결 대기가 보이면 참조하는 Scene과 Array를 이어서 가져와 <code>sceneId</code>와 <code>arrayId</code>를 맞추세요.</p> <p>참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. 실행할 수 없다면 검증 카드에서 Nyquist, scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.</p>
</details> </details>
<details> <details>
<summary>Assets가 준비되지 않습니다.</summary> <summary>Assets가 준비되지 않습니다.</summary>
+81 -20
View File
@@ -1,4 +1,4 @@
import { validateResult } from "/packages/contracts/validation.js"; import { validateExperiment, validateResult } from "/packages/contracts/validation.js";
async function waitFor(predicate, label, timeoutMs = 30000) { async function waitFor(predicate, label, timeoutMs = 30000) {
const deadline = performance.now() + timeoutMs; const deadline = performance.now() + timeoutMs;
@@ -344,7 +344,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
} }
}); });
test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 다중 분석 대역을 보존한다", async () => { test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Array 재import에서도 다중 대역을 보존한다", async () => {
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
frame.src = "/apps/acoustic-camera/?integration-test=1&band-preservation=1"; frame.src = "/apps/acoustic-camera/?integration-test=1&band-preservation=1";
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0"; frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
@@ -393,7 +393,8 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서
assert(link.relList.contains("noopener"), "Builder tab cannot replace Camera through opener"); assert(link.relList.contains("noopener"), "Builder tab cannot replace Camera through opener");
} }
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); const openScene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
const scene = structuredClone(openScene);
scene.id = "two-tone-band-preservation"; scene.id = "two-tone-band-preservation";
scene.name = "Two-tone band preservation"; scene.name = "Two-tone band preservation";
const secondSource = structuredClone(scene.sources[0]); const secondSource = structuredClone(scene.sources[0]);
@@ -404,7 +405,10 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서
secondSource.signal.phaseDeg = 45; secondSource.signal.phaseDeg = 45;
scene.sources.push(secondSource); scene.sources.push(secondSource);
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); const openArrayDefinition = await loadJson(
"/assets/array-geometries/rectangular-16x12.array.json",
);
const arrayDefinition = structuredClone(openArrayDefinition);
arrayDefinition.id = "custom-array-band-preservation"; arrayDefinition.id = "custom-array-band-preservation";
arrayDefinition.name = "Custom array band preservation"; arrayDefinition.name = "Custom array band preservation";
@@ -427,13 +431,15 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서
); );
dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]); dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]);
await waitFor( await waitFor(
() => documentObject.documentElement.dataset.inputBusy === "false", () => documentObject.documentElement.dataset.inputBusy === "false" &&
"clean-state Experiment-first import", documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"Experiment import and automatic reference binding",
); );
assertEqual( assertEqual(
documentObject.querySelector("#experiment-id").value, documentObject.querySelector("#experiment-id").value,
experiment.id, experiment.id,
"Experiment loads before its referenced Scene", "Experiment document loads before its originally referenced Scene",
); );
assertEqual(readBandEditors().length, 2, "Experiment-first import renders every analysis band"); assertEqual(readBandEditors().length, 2, "Experiment-first import renders every analysis band");
assertEqual( assertEqual(
@@ -441,22 +447,77 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서
JSON.stringify(expectedBands), JSON.stringify(expectedBands),
"Experiment-first import preserves band IDs, ranges, and colors", "Experiment-first import preserves band IDs, ranges, and colors",
); );
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "false" &&
documentObject.querySelector("#validation-message").textContent.includes("sceneId") &&
documentObject.querySelector("#validation-message").textContent.includes("arrayId"),
"pending Scene and Array linkage validation",
);
assert( assert(
documentObject.querySelector("#validation-message").textContent.includes("다릅니다"), !documentObject.querySelector("#validation-message").textContent.includes("sceneId") &&
"Experiment-first import explains the pending document ID mismatch", !documentObject.querySelector("#validation-message").textContent.includes("arrayId") &&
!documentObject.querySelector("#run-status").textContent.includes("연결 대기"),
"automatic binding does not leave a reference mismatch warning",
); );
assert( assert(
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") && documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") &&
documentObject.querySelector("#run-status").textContent.includes(scene.id) && documentObject.querySelector("#run-status").textContent.includes("실행 준비 완료"),
documentObject.querySelector("#run-status").textContent.includes(arrayDefinition.id) && "Experiment import reaches a ready state with every band",
documentObject.querySelector("#run-status").textContent.includes("연결 대기"), );
"Experiment-first status reports every band and pending reference",
documentObject.querySelector("#export-experiment-button").click();
const reboundDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
"automatically rebound Experiment export",
);
const reboundExperiment = await fetch(reboundDownload.url).then((response) => response.json());
assertEqual(reboundExperiment.sceneId, openScene.id, "import binds the open Scene ID");
assertEqual(
reboundExperiment.arrayId,
openArrayDefinition.id,
"import binds the open Array ID",
);
assertEqual(
JSON.stringify(reboundExperiment.frequencyBands),
JSON.stringify(expectedBands),
"automatic reference binding preserves every imported band",
);
assertEqual(
(await validateExperiment(reboundExperiment, {
scene: openScene,
arrayDefinition: openArrayDefinition,
})).length,
0,
"rebound Experiment passes relationship validation",
);
downloads.length = 0;
const contextInvalidExperiment = structuredClone(experiment);
contextInvalidExperiment.id = "nyquist-invalid-after-rebind";
contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2;
assertEqual(
(await validateExperiment(contextInvalidExperiment)).length,
0,
"Nyquist fixture is valid without a Scene context",
);
const contextInvalidFile = new windowObject.File(
[JSON.stringify(contextInvalidExperiment)],
"nyquist-invalid-after-rebind.experiment.json",
{ type: "application/json" },
);
dispatchFiles(
windowObject,
documentObject.querySelector("#experiment-input"),
[contextInvalidFile],
);
await waitFor(
() => documentObject.documentElement.dataset.inputBusy === "false" &&
documentObject.documentElement.dataset.cameraValid === "true",
"relationship-invalid Experiment rejection",
);
assertEqual(
documentObject.querySelector("#experiment-id").value,
experiment.id,
"context relationship validation rejects the imported Experiment before assignment",
);
assertEqual(
JSON.stringify(readBandEditors()),
JSON.stringify(expectedBands),
"failed relationship validation leaves the current bands unchanged",
); );
let replacementConfirmCount = 0; let replacementConfirmCount = 0;
@@ -487,7 +548,7 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서
() => documentObject.documentElement.dataset.inputBusy === "false" && () => documentObject.documentElement.dataset.inputBusy === "false" &&
documentObject.querySelectorAll("#source-summary .source-line").length === 2 && documentObject.querySelectorAll("#source-summary .source-line").length === 2 &&
documentObject.querySelectorAll("#band-list .band-editor").length === 2 && documentObject.querySelectorAll("#band-list .band-editor").length === 2 &&
documentObject.documentElement.dataset.cameraValid === "false", documentObject.documentElement.dataset.cameraValid === "true",
"source-added Scene reimport", "source-added Scene reimport",
); );
assertEqual( assertEqual(