diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html index db6222f..1c7df58 100644 --- a/apps/dataset-builder/index.html +++ b/apps/dataset-builder/index.html @@ -15,11 +15,24 @@ micarrsim Dataset Builder +
- - Scene 저장 후 Camera 열기 → +
+ 더보기 +
+ + Acoustic Camera 열기 → +
+
@@ -27,39 +40,40 @@
-

Ground-truth canvas

+

01 · 입력과 배치

음향 장면

+ -
- 이미지를 추가하거나 grid를 그대로 사용하세요. - 선택한 음원을 옮기려면 canvas를 클릭하세요. + 음원을 선택하고 canvas를 클릭해 위치를 지정하세요. + 배경 이미지는 선택 사항입니다.
x −0.60…0.60 m · y −0.45…0.45 m
-

음원 ground truth

-

파일 음원은 선택 즉시 생성됩니다. 카드를 선택한 뒤 canvas를 클릭해 위치를 지정하세요.

+

02 · 음원 편집

+

음원과 이동 경로

+

카드를 선택한 뒤 canvas를 클릭하면 현재 위치 또는 선택한 경로점이 이동합니다.

@@ -73,8 +87,11 @@
-

로컬 assets

-

원본은 업로드되지 않고 브라우저에서만 처리됩니다. PCM WAV를 권장하며, 다른 codec 지원은 환경에 따라 다릅니다.
Scene을 다시 불러온 뒤에는 위의 원본 오디오 연결에서 같은 파일을 선택하세요.

+

연결된 원본 파일

+
+ 파일 연결 도움말 +

원본은 업로드되지 않고 브라우저에서만 처리됩니다. Scene을 다시 열었다면 위의 원본 재연결에서 같은 파일을 선택하세요. PCM WAV를 권장합니다.

+
@@ -86,40 +103,13 @@ 기본 장면
-
-
01

장면

- - -
- - - - - - -
+
+
+
검증 대기

장면을 준비하고 있습니다.

-
-
02

표시 평면

-
- - - - - -
-

x/y는 편집 화면 범위이며, 배경 이미지가 있을 때 image plane으로 JSON에 저장됩니다.

-
- -
-
03

선택 음원

+
+
02

선택 음원 편집

음원을 선택하세요.
@@ -135,13 +125,13 @@
-

경로 시간은 Scene 시작 기준입니다. 이동을 켜면 현재 위치에서 시작해 Scene 끝까지 이어지는 두 점 경로가 만들어집니다.

+

켜면 현재 위치부터 Scene 끝까지의 기본 경로가 만들어집니다.

@@ -161,7 +151,42 @@
-
04

Ambient

+
설정

장면 기본값

+ + + +
+ 고급 음향·믹스 설정 +
+ + + + + +
+
+
+ +
+ 범위표시 평면고급 +
+ + + + + +
+

x/y는 canvas 범위이며 배경이 있으면 image plane으로 저장됩니다.

+
+ +
+ 배경음Ambient선택 사항
@@ -169,12 +194,7 @@
-
- -
-
-
검증 대기

장면을 준비하고 있습니다.

-
+
diff --git a/apps/dataset-builder/styles.css b/apps/dataset-builder/styles.css index c77a85a..0ce885c 100644 --- a/apps/dataset-builder/styles.css +++ b/apps/dataset-builder/styles.css @@ -643,3 +643,363 @@ grid-row: 2; } } + +/* Task-first Builder navigation and progressive disclosure. */ +.builder-header { + min-height: 58px; + padding-block: 9px; + gap: 16px; +} + +.builder-workflow { + min-width: 0; + margin-left: auto; +} + +.builder-workflow ol { + display: flex; + align-items: center; + margin: 0; + padding: 0; + list-style: none; +} + +.builder-workflow li { + display: flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 680; + white-space: nowrap; +} + +.builder-workflow li + li::before { + width: 18px; + height: 1px; + margin: 0 7px; + background: var(--line); + content: ""; +} + +.builder-workflow li span { + display: grid; + width: 20px; + height: 20px; + border: 1px solid rgb(101 242 191 / 34%); + border-radius: 50%; + place-items: center; + color: var(--mint); + font: 750 0.6rem ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.header-more { + position: relative; +} + +.header-more > summary { + list-style: none; +} + +.header-more > summary::-webkit-details-marker, +.inline-disclosure > summary::-webkit-details-marker, +.section-summary::-webkit-details-marker, +.asset-help > summary::-webkit-details-marker { + display: none; +} + +.header-more__menu { + position: absolute; + z-index: 30; + top: calc(100% + 8px); + right: 0; + display: grid; + width: max-content; + min-width: 210px; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: #0b1714; + box-shadow: 0 18px 48px rgb(0 0 0 / 35%); +} + +.header-more__menu .button { + width: 100%; +} + +.builder-layout { + grid-template-columns: minmax(0, 1fr) minmax(320px, 370px); + min-height: calc(100vh - 59px); +} + +.scene-workspace { + padding: clamp(20px, 2.5vw, 34px); +} + +.workspace-heading { + margin-bottom: 16px; +} + +.workspace-heading h1 { + font-size: clamp(1.55rem, 3vw, 2.35rem); +} + +.source-strip-heading .eyebrow { + margin-bottom: 5px; + font-size: 0.64rem; +} + +.source-strip-heading { + margin-top: 24px; +} + +.asset-shelf { + align-items: center; + margin-top: 20px; + padding: 14px 16px; +} + +.asset-help { + color: var(--muted); + font-size: 0.7rem; +} + +.asset-help summary { + width: fit-content; + margin-top: 4px; + color: var(--mint); + cursor: pointer; +} + +.asset-help p { + max-width: 520px; + margin-top: 9px; +} + +.control-panel { + top: 59px; + max-height: calc(100vh - 59px); + padding: 14px; +} + +.control-section--primary { + border-color: rgb(101 242 191 / 28%); + background: rgb(101 242 191 / 3%); +} + +.validation-card { + margin-top: 0; + margin-bottom: 12px; + padding: 13px 15px; +} + +.control-section + .validation-card { + margin-top: 12px; +} + +.inline-disclosure { + margin-top: 12px; + border-top: 1px solid var(--line); +} + +.inline-disclosure > summary { + padding: 12px 0 0; + color: var(--muted); + cursor: pointer; + font-size: 0.7rem; + font-weight: 700; +} + +.inline-disclosure > summary::after { + float: right; + color: var(--mint); + content: "+"; +} + +.inline-disclosure[open] > summary::after { + content: "−"; +} + +.disclosure-section { + padding: 0; +} + +.section-summary { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 9px; + padding: 15px 17px; + cursor: pointer; + list-style: none; +} + +.section-summary > span { + color: var(--mint); + font: 750 0.62rem ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.section-summary > strong { + font-size: 0.82rem; +} + +.section-summary > small { + color: var(--muted); + font-size: 0.62rem; +} + +.disclosure-section[open] { + padding: 0 17px 17px; +} + +.disclosure-section[open] .section-summary { + margin: 0 -17px 14px; + border-bottom: 1px solid var(--line); +} + +@media (max-width: 1180px) { + .builder-workflow li { + font-size: 0; + gap: 0; + } + + .builder-workflow li + li::before { + width: 12px; + margin-inline: 5px; + } +} + +@media (max-width: 1100px) { + .control-panel { + max-height: none; + } +} + +@media (max-width: 760px) { + .builder-header { + gap: 10px; + } + + .builder-workflow { + order: 3; + width: 100%; + margin: 0; + } + + .builder-workflow ol { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .builder-workflow li { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 5px; + font-size: 0.64rem; + } + + .builder-workflow li + li::before { + display: none; + } + + .header-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .header-actions > .button, + .header-actions > .header-more, + .header-more > summary { + width: 100%; + } + + .header-more__menu { + top: calc(100% + 6px); + } + + .asset-shelf { + align-items: stretch; + } +} + +@media (max-width: 430px) { + .builder-header, + .scene-workspace, + .control-panel { + padding-inline: 14px; + } + + .builder-workflow li { + grid-template-columns: 18px minmax(0, 1fr); + font-size: 0.59rem; + } + + .builder-workflow li span { + width: 18px; + height: 18px; + } + + .header-actions { + gap: 6px; + } + + .header-actions .button { + min-height: 36px; + padding-inline: 7px; + font-size: 0.72rem; + } + + .header-more__menu { + right: -2px; + min-width: 190px; + } + + .canvas-card { + min-height: 360px; + } + + #scene-canvas { + height: 360px; + } + + .workspace-tools .button, + .source-actions .button { + font-size: 0.74rem; + } + + .trajectory-row { + grid-template-columns: 34px repeat(2, minmax(0, 1fr)) 30px; + grid-template-rows: repeat(2, auto); + } + + .trajectory-row__select, + .trajectory-row__remove { + grid-row: 1 / 3; + align-self: stretch; + } + + .trajectory-row .field:nth-of-type(1) { + grid-column: 2; + grid-row: 1; + } + + .trajectory-row .field:nth-of-type(2) { + grid-column: 3; + grid-row: 1; + } + + .trajectory-row .field:nth-of-type(3) { + grid-column: 2; + grid-row: 2; + } + + .trajectory-row .field:nth-of-type(4) { + grid-column: 3; + grid-row: 2; + } + + .trajectory-row__remove { + grid-column: 4; + } +} diff --git a/index.html b/index.html index 58b5c0c..848ee7c 100644 --- a/index.html +++ b/index.html @@ -11,90 +11,57 @@ +
+ micarrsim +
+ 그래픽 장치 확인 중… + 파일은 이 기기에서 처리됩니다 +
+ 사용 설명서 +
+
-
-

192-channel acoustic camera laboratory

-

micarrsim

-

- 합성 음향 장면을 만들고, 마이크 배열의 자유음장 응답과 DAS 음향 영상을 같은 조건에서 - 비교합니다. -

-
- 그래픽 장치 확인 중… - 로컬 처리 · 외부 업로드 없음 -
+
+

Microphone array workbench

+

무엇을 하시겠어요?

+

배열 위치추정을 바로 실행하거나, 평가에 사용할 음원 장면을 먼저 만드세요.

-
- -
    -
  1. Dataset Builder음원과 위치 만들기
  2. -
  3. Scene JSON + 원본 assets같이 보관하기
  4. -
  5. Acoustic Camera배열·DAS 실행하기
  6. -
  7. Result JSONmap·지표 저장하기
  8. -
-

- 원본 파일은 JSON에 포함되거나 외부로 업로드되지 않습니다. Camera에서 같은 파일을 다시 - 선택하면 SHA-256으로 확인한 뒤 연결합니다. -

-
+ Camera 열기 → + -
- 01 +
+

평가 장면 제작

Dataset Builder

-

기기 이미지에 음원을 배치하고 ambient를 혼합해 재현 가능한 장면을 만듭니다.

+

음원 파일과 이동 경로를 배치해 정답 위치가 있는 Scene을 만듭니다.

새 Scene 만들기 →
- - - 02 -
-

Acoustic Camera

-

배열 응답과 DAS 결과를 비교하고 이동 음원의 acoustic map을 시간축으로 재생합니다.

-
- 기본 예제로 바로 실행 → -
-
-
-

30초 확인

-

설정 없이 첫 결과 만들기

-

Acoustic Camera를 열어 ▶ 기본 예제 실행을 누르고, 완료되면 Result JSON을 저장하세요.

- 기본 예제 열기 → -
-
-

이동 음원

-

설정 없이 라이브 재생

-

Camera에서 이동 예제 → 라이브 준비 → 재생만 누르면 됩니다.

- 이동 예제 열기 → -
-
-

내 데이터

-

Builder부터 끝까지

-

Scene을 저장한 뒤 Camera에서 Scene → Array → Experiment → Assets 순서로 가져오면 참조 오류를 줄일 수 있습니다.

- 5분 안내 보기 → -
-
- - +
+ 내 파일로 분석하는 순서 +
    +
  1. Scene 제작Builder에서 음원과 위치 지정
  2. +
  3. 파일 보관Scene JSON과 원본 파일을 함께 보관
  4. +
  5. Camera 입력Scene, 배열, 원본 파일 선택
  6. +
  7. 결과 저장위치 map과 지표를 Result JSON으로 저장
  8. +
+

+ Scene JSON에는 원본 파일이 포함되지 않습니다. 다시 열 때 같은 파일을 선택하면 SHA-256으로 + 확인해 연결합니다. 자세히 보기 +

+
diff --git a/packages/ui/app-shell.css b/packages/ui/app-shell.css index 5e72f2d..f5387cc 100644 --- a/packages/ui/app-shell.css +++ b/packages/ui/app-shell.css @@ -396,3 +396,218 @@ p { transition-duration: 0.01ms !important; } } + +/* Compact, task-first landing page. */ +.landing-header { + display: flex; + width: min(1120px, calc(100% - 40px)); + min-height: 64px; + margin: 0 auto; + align-items: center; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid var(--line); +} + +.landing-brand, +.landing-docs { + color: inherit; + font-weight: 760; + text-decoration: none; +} + +.landing-brand { + font-size: 1rem; + letter-spacing: -0.035em; +} + +.landing-docs { + color: var(--muted); + font-size: 0.78rem; +} + +.landing-docs:hover, +.landing-docs:focus-visible { + color: var(--text); +} + +.landing-header .status-row { + justify-content: center; + margin-left: auto; +} + +.landing-shell { + width: min(960px, calc(100% - 40px)); + padding: clamp(44px, 8vw, 84px) 0 36px; +} + +.landing-intro { + max-width: 720px; +} + +.landing-intro .eyebrow { + margin-bottom: 10px; +} + +.landing-intro h1 { + margin-bottom: 12px; + font-size: clamp(2.25rem, 6vw, 4.2rem); + font-weight: 760; + letter-spacing: -0.065em; + line-height: 1; +} + +.landing-intro > p:last-child { + margin-bottom: 0; + color: #c8d9d3; + font-size: clamp(0.95rem, 2vw, 1.1rem); + line-height: 1.6; +} + +.landing-shell .launcher-grid { + gap: 16px; + margin-top: 28px; +} + +.landing-shell .launcher-card { + grid-template-rows: auto 1fr auto; + min-height: 250px; + padding: clamp(22px, 4vw, 30px); + border-radius: 20px; + box-shadow: 0 16px 48px rgb(0 0 0 / 20%); +} + +.landing-shell .launcher-card:hover, +.landing-shell .launcher-card:focus-visible { + transform: translateY(-3px); +} + +.launcher-card__number { + font: 750 0.7rem ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.launcher-card__kicker { + margin: 28px 0 6px !important; + color: var(--mint) !important; + font-size: 0.7rem; + font-weight: 760; + letter-spacing: 0.08em; +} + +.launcher-card--accent .launcher-card__kicker { + color: var(--cyan) !important; +} + +.landing-shell .launcher-card h2 { + margin-bottom: 10px; + font-size: clamp(1.45rem, 3vw, 2rem); +} + +.landing-shell .launcher-card p { + line-height: 1.6; +} + +.landing-shell .launcher-card__action { + margin-top: 24px; + font-size: 0.84rem; +} + +.workflow-disclosure { + margin-top: 16px; + padding: 0 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgb(255 255 255 / 2%); +} + +.workflow-disclosure summary { + padding: 16px 0; + color: #c8d9d3; + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; +} + +.workflow-disclosure[open] summary { + border-bottom: 1px solid var(--line); +} + +.workflow-disclosure .workflow-list { + margin: 16px 0 12px; +} + +.workflow-disclosure .workflow-list li { + min-height: 78px; + padding: 12px; + border-radius: 10px; +} + +.workflow-disclosure .workflow-list li::before { + margin-bottom: 9px; + font-size: 0.62rem; +} + +.workflow-disclosure .workflow-list strong { + font-size: 0.78rem; +} + +.workflow-disclosure .workflow-list span { + margin-top: 4px; + font-size: 0.68rem; + line-height: 1.4; +} + +.workflow-disclosure .workflow-note { + margin: 0 0 16px; + font-size: 0.74rem; + line-height: 1.55; +} + +.workflow-disclosure .workflow-note a { + color: var(--mint); +} + +@media (max-width: 720px) { + .landing-header { + min-height: 58px; + gap: 10px; + } + + .landing-header .status-chip--neutral { + display: none; + } + + .landing-shell .launcher-card { + min-height: 220px; + } +} + +@media (max-width: 430px) { + .landing-header, + .landing-shell { + width: min(100% - 28px, 960px); + } + + .landing-header { + flex-wrap: wrap; + padding-top: 12px; + } + + .landing-header .status-row { + order: 3; + width: 100%; + margin: 0 0 10px; + } + + .landing-shell { + padding-top: 36px; + } + + .landing-intro h1 { + font-size: 2.25rem; + } + + .landing-shell .launcher-card { + min-height: 205px; + } +} diff --git a/tests/integration/builder-ui.test.js b/tests/integration/builder-ui.test.js index 69efd91..161431e 100644 --- a/tests/integration/builder-ui.test.js +++ b/tests/integration/builder-ui.test.js @@ -36,6 +36,33 @@ async function digestHex(blob) { return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); } +test("시작 화면과 Dataset Builder가 작업 우선순위 및 390px 레이아웃을 유지한다", async () => { + const landingFrame = document.createElement("iframe"); + landingFrame.src = "/"; + landingFrame.style.cssText = "position:fixed;left:-20000px;top:0;width:390px;height:900px;border:0"; + document.body.append(landingFrame); + await new Promise((resolve, reject) => { + landingFrame.addEventListener("load", resolve, { once: true }); + landingFrame.addEventListener("error", () => reject(new Error("Landing iframe load failed")), { once: true }); + }); + + try { + const landingDocument = landingFrame.contentDocument; + const launchers = [...landingDocument.querySelectorAll(".launcher-card")]; + assertEqual(launchers.length, 2, "landing exposes only two primary tasks"); + assert(launchers[0].textContent.includes("Acoustic Camera"), "analysis is the first landing task"); + assert(launchers[1].textContent.includes("Dataset Builder"), "scene authoring is the second landing task"); + assert(!landingDocument.querySelector(".workflow-disclosure").open, "file workflow starts collapsed"); + assert(!landingDocument.querySelector('a[href="/docs/execution-plan.md"]'), "developer plan is absent from landing"); + assert( + landingDocument.documentElement.scrollWidth <= landingDocument.documentElement.clientWidth + 1, + "landing has no horizontal overflow at 390px", + ); + } finally { + landingFrame.remove(); + } +}); + test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 preview WAV를 재현한다", async () => { const frame = document.createElement("iframe"); frame.src = "/apps/dataset-builder/?integration-test=1"; @@ -61,6 +88,47 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev await waitFor(() => documentObject.documentElement.dataset.appReady === "true", "Builder 초기화"); await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "기본 scene 검증"); assert(documentObject.querySelector("#scene-id").value !== "single-tone", "new default scene gets a unique id"); + const workflowSteps = [...documentObject.querySelectorAll("#builder-workflow [data-workflow-step]")]; + assertEqual(workflowSteps.length, 4, "four Builder workflow steps are discoverable"); + assertEqual( + workflowSteps.map((step) => step.dataset.workflowStep).join(","), + "input,edit,validate,save", + "Builder workflow order", + ); + assert(!documentObject.querySelector(".header-more").open, "secondary export actions start collapsed"); + assert(!documentObject.querySelector(".inline-disclosure").open, "advanced scene settings start collapsed"); + assert( + [...documentObject.querySelectorAll(".disclosure-section")].every((section) => !section.open), + "optional plane and ambient settings start collapsed", + ); + assert( + documentObject.querySelector("#source-editor-section").compareDocumentPosition( + documentObject.querySelector("#scene-name").closest(".control-section"), + ) & windowObject.Node.DOCUMENT_POSITION_FOLLOWING, + "selected source editor precedes scene metadata", + ); + assert( + documentObject.querySelector("#scene-canvas").getBoundingClientRect().height >= 500, + "canvas remains the dominant Builder workspace", + ); + + frame.style.width = "390px"; + await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve))); + assert( + documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1, + "Builder has no horizontal overflow at 390px", + ); + const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect(); + assert( + finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1, + "all Builder workflow steps remain visible at 390px", + ); + assert( + documentObject.querySelector("#scene-canvas").getBoundingClientRect().height >= 350, + "Builder canvas remains usable at 390px", + ); + frame.style.width = "1400px"; + await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve))); const previewSamples = new Float32Array(2000); for (let index = 0; index < previewSamples.length; index += 1) {