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
+
@@ -27,39 +40,40 @@
- 이미지를 추가하거나 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 @@
기본 장면
-
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 @@
+
+
-
- 192-channel acoustic camera laboratory
- micarrsim
-
- 합성 음향 장면을 만들고, 마이크 배열의 자유음장 응답과 DAS 음향 영상을 같은 조건에서
- 비교합니다.
-
-
- 그래픽 장치 확인 중…
- 로컬 처리 · 외부 업로드 없음
-
+
+ Microphone array workbench
+ 무엇을 하시겠어요?
+ 배열 위치추정을 바로 실행하거나, 평가에 사용할 음원 장면을 먼저 만드세요.
-
-
-
- - Dataset Builder음원과 위치 만들기
- - Scene JSON + 원본 assets같이 보관하기
- - Acoustic Camera배열·DAS 실행하기
- - Result JSONmap·지표 저장하기
-
-
- 원본 파일은 JSON에 포함되거나 외부로 업로드되지 않습니다. Camera에서 같은 파일을 다시
- 선택하면 SHA-256으로 확인한 뒤 연결합니다.
-
-
+ Camera 열기 →
+
-
-
-
- 30초 확인
- 설정 없이 첫 결과 만들기
- Acoustic Camera를 열어 ▶ 기본 예제 실행을 누르고, 완료되면 Result JSON을 저장하세요.
- 기본 예제 열기 →
-
-
- 이동 음원
- 설정 없이 라이브 재생
- Camera에서 이동 예제 → 라이브 준비 → 재생만 누르면 됩니다.
- 이동 예제 열기 →
-
-
- 내 데이터
- Builder부터 끝까지
- Scene을 저장한 뒤 Camera에서 Scene → Array → Experiment → Assets 순서로 가져오면 참조 오류를 줄일 수 있습니다.
- 5분 안내 보기 →
-
-
-
-
+
+ 내 파일로 분석하는 순서
+
+ - Scene 제작Builder에서 음원과 위치 지정
+ - 파일 보관Scene JSON과 원본 파일을 함께 보관
+ - Camera 입력Scene, 배열, 원본 파일 선택
+ - 결과 저장위치 map과 지표를 Result JSON으로 저장
+
+
+ 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) {