feat: refocus acoustic camera workflow

This commit is contained in:
Codex
2026-08-28 18:08:52 +09:00
parent fd3d98947a
commit bc63ada1cd
3 changed files with 622 additions and 249 deletions
+172 -108
View File
@@ -13,38 +13,182 @@
<header class="app-header camera-header">
<a class="app-brand" href="/"><strong>micarrsim</strong><span>Acoustic Camera</span></a>
<div class="header-status">
<span class="run-status" id="run-status">기본 입력 준비 중…</span>
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<span class="run-status" id="run-status">기본 fixture 준비 중…</span>
</div>
<div class="header-actions">
<button class="button button--ghost is-hidden" id="cancel-button" type="button">계산 취소</button>
<button class="button button--ghost" id="export-result-button" type="button" disabled>Result JSON</button>
<button class="button button--primary" id="run-button" type="button" disabled>▶ 기본 예제 실행</button>
</div>
</header>
<nav class="workflow-strip" id="camera-workflow" aria-label="Acoustic Camera 작업 순서">
<a href="#input-step" data-workflow-step="1"><span>1</span><strong>입력</strong><small>Scene · Array</small></a>
<a href="#configure-step" data-workflow-step="2"><span>2</span><strong>설정</strong><small>Grid · Band</small></a>
<a href="#run-step" data-workflow-step="3"><span>3</span><strong>실행</strong><small>정지 · 이동</small></a>
<a href="#inspect-step" data-workflow-step="4"><span>4</span><strong>확인</strong><small>Map · 지표</small></a>
</nav>
<main class="camera-layout">
<aside class="control-panel" aria-label="입력 및 시뮬레이션 설정">
<section class="workflow-card input-card" id="input-step" data-workflow-section="1">
<div class="step-heading">
<span class="step-number">1</span>
<div><h2>입력 준비</h2><p>기본 예제를 쓰거나 파일을 순서대로 교체하세요.</p></div>
</div>
<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="사용자 배열이 있을 때 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="현재 Scene과 Array를 참조하는 Experiment JSON을 선택하세요."><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="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 class="input-summary">
<div class="compact-section-heading"><h3>현재 장면</h3><a class="builder-link" href="/apps/dataset-builder/">Builder에서 편집 →</a></div>
<div class="source-summary" id="source-summary"></div>
<div class="asset-list" id="asset-list"></div>
</div>
<div class="array-card">
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3></div><strong id="microphone-count">— ch</strong></div>
<label class="field"><span>배열 선택</span><select id="array-preset"><option value="rectangular-16x12">16×12 rectangular · 기준</option><option value="fermat-spiral-192">Fermat spiral · 비교</option><option value="custom">불러온 Array JSON</option></select></label>
<details class="inline-details" id="array-details">
<summary>배열 미리보기와 좌표 정보</summary>
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
<p id="array-meta">배열을 불러오고 있습니다.</p>
</details>
</div>
</section>
<section class="workflow-card configure-card" id="configure-step" data-workflow-section="2">
<div class="step-heading">
<span class="step-number">2</span>
<div><h2>분석 설정</h2><p>자주 쓰는 항목만 먼저 조정합니다.</p></div>
</div>
<div class="quick-field-grid">
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
<label class="field"><span>Grid preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
</div>
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
<div class="band-config">
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
<div class="band-editor-list" id="band-list"></div>
</div>
<details class="advanced-settings" id="advanced-settings">
<summary><span>고급 설정</span><small>범위 · STFT · 표시 · 식별자</small></summary>
<div class="advanced-settings__body">
<div class="advanced-group">
<h3>실험과 환경</h3>
<div class="advanced-field-grid">
<label class="field field--wide"><span>이름</span><input id="experiment-name" type="text" maxlength="120" /></label>
<label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label>
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
</div>
</div>
<div class="advanced-group">
<h3>Scan 범위</h3>
<div class="advanced-field-grid">
<label class="field"><span>Columns</span><input id="scan-columns" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>Rows</span><input id="scan-rows" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>x min (m)</span><input id="scan-x-min" type="number" step="0.05" /></label>
<label class="field"><span>x max (m)</span><input id="scan-x-max" type="number" step="0.05" /></label>
<label class="field"><span>y min (m)</span><input id="scan-y-min" type="number" step="0.05" /></label>
<label class="field"><span>y max (m)</span><input id="scan-y-max" type="number" step="0.05" /></label>
</div>
</div>
<div class="advanced-group">
<h3>STFT와 표시</h3>
<div class="advanced-field-grid">
<label class="field"><span>FFT size</span><select id="fft-size"><option>128</option><option>256</option><option selected>512</option><option>1024</option><option>2048</option><option>4096</option></select></label>
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
</div>
</div>
</div>
</details>
<details class="output-card utility-details" id="document-tools">
<summary>Scene · Experiment 문서 저장</summary>
<div class="output-actions">
<button class="button button--ghost" id="export-scene-button" type="button">Scene JSON</button>
<button class="button button--ghost" id="export-experiment-button" type="button">Experiment JSON</button>
</div>
<p class="output-save-state" id="output-save-state">저장하지 않은 변경 없음</p>
</details>
</section>
<section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div>
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">입력을 준비하고 있습니다.</p></div>
</section>
</aside>
<section class="scene-workspace" aria-label="Acoustic image workspace">
<div class="workspace-heading">
<div>
<p class="eyebrow">Ground truth + acoustic overlay</p>
<h1 id="workspace-title">음향 장면</h1>
<div><span class="compact-label">CURRENT SCENE</span><h1 id="workspace-title">음향 장면</h1></div>
<p class="workspace-meta" id="scene-meta">장면을 불러오고 있습니다.</p>
</div>
<div class="workspace-import">
<div class="workspace-tools" aria-label="입력 파일">
<label class="button button--ghost file-button" title="1. Scene JSON을 먼저 선택하세요.">Scene<input id="scene-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button" title="2. 사용자 배열이 있을 때 Array JSON을 선택하세요.">Array<input id="array-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button" title="3. 현재 Scene과 Array를 참조하는 Experiment JSON을 선택하세요.">Experiment<input id="experiment-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button" title="4. Scene에 사용한 원본 파일을 다시 선택하세요.">Assets<input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
<section class="run-stage" id="run-step" data-workflow-section="3" aria-labelledby="run-step-title">
<div class="step-heading step-heading--horizontal">
<span class="step-number">3</span>
<div><h2 id="run-step-title">실행 방식 선택</h2><p>한 장을 계산할지, 이동 경로를 재생할지 선택하세요.</p></div>
</div>
<p class="import-order">권장 순서 · Scene → Array → Experiment → Assets</p>
<div class="mode-grid">
<article class="mode-card mode-card--snapshot" id="snapshot-mode">
<div class="mode-heading">
<div><span class="mode-kicker">SNAPSHOT</span><h3>정지 장면</h3></div>
<span class="mode-format">1 map</span>
</div>
<p>현재 위치와 설정으로 전체 장면을 한 번 분석합니다.</p>
<div class="mode-actions">
<button class="button button--primary button--full" id="run-button" type="button" disabled>▶ Snapshot 실행</button>
</div>
</article>
<article class="mode-card mode-card--moving live-panel" id="moving-mode" aria-labelledby="live-title">
<div class="mode-heading">
<div><span class="mode-kicker">MOVING TIMELINE</span><h3 id="live-title">이동 장면</h3></div>
<span class="mode-format">여러 map</span>
</div>
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
<div class="live-actions">
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제 열기</button>
<button class="button button--primary" id="prepare-live-button" type="button" disabled>Map 준비</button>
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
</div>
<div class="live-controls">
<label class="live-scrubber"><span id="live-time-label">0.00 / 0.00 s</span><input id="live-time" type="range" min="0" max="0" step="0.001" value="0" disabled /></label>
<label class="compact-select"><span>Map rate</span><select id="live-map-rate"><option value="2" selected>2 Hz</option><option value="5">5 Hz</option><option value="8">8 Hz</option></select></label>
<label class="compact-select"><span>속도</span><select id="live-speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></label>
</div>
<details class="mode-help"><summary>동작 방식과 한계</summary><p class="live-note">짧은 분석창 중앙의 위치를 쓰는 준정적 시뮬레이션입니다. Heatmap과 위치를 frame 사이에서 보간하며 실제 마이크 스트림 입력은 아닙니다.</p></details>
</article>
</div>
</section>
<section class="inspect-stage" id="inspect-step" data-workflow-section="4" aria-labelledby="inspect-title">
<div class="inspect-heading">
<div class="step-heading step-heading--horizontal">
<span class="step-number">4</span>
<div><h2 id="inspect-title">Map과 결과 확인</h2><p>Canvas가 실제 음향 에너지와 추정 위치를 표시합니다.</p></div>
</div>
<span class="result-chip" id="result-badge">아직 실행하지 않음</span>
</div>
<div class="scene-canvas-card">
<canvas id="scene-canvas" aria-label="Ground truth와 주파수별 acoustic image"></canvas>
<div class="canvas-message" id="canvas-message"><strong>기본 synthetic scene이 준비됩니다.</strong><span>실행하면 주파수별 acoustic image가 이 평면에 겹쳐집니다.</span></div>
<div class="canvas-message" id="canvas-message"><strong>실행할 준비가 되면 위에서 모드를 선택하세요.</strong><span>결과 heatmap은 이 canvas에 표시됩니다.</span></div>
<div class="canvas-badge" id="canvas-badge">결과 없음</div>
</div>
@@ -55,111 +199,31 @@
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<section class="live-panel" aria-labelledby="live-title">
<div class="live-heading">
<div>
<p class="eyebrow">Moving-source timeline</p>
<h2 id="live-title">이동 음원 라이브</h2>
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
</div>
<div class="live-actions">
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제</button>
<button class="button button--primary" id="prepare-live-button" type="button" disabled>라이브 준비</button>
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
</div>
</div>
<div class="live-controls">
<label class="live-scrubber"><span id="live-time-label">0.00 / 0.00 s</span><input id="live-time" type="range" min="0" max="0" step="0.001" value="0" disabled /></label>
<label class="compact-select"><span>Map rate</span><select id="live-map-rate"><option value="2" selected>2 Hz</option><option value="5">5 Hz</option><option value="8">8 Hz</option></select></label>
<label class="compact-select"><span>속도</span><select id="live-speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></label>
</div>
<p class="live-note">짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. Heatmap은 계산된 frame 사이를 보간하고 위치 표시는 연속 이동합니다. 시뮬레이션 장면 재생이며 실제 마이크 스트림 입력은 아닙니다.</p>
</section>
<section class="result-section" aria-live="polite">
<div class="section-heading"><div><p class="eyebrow">Evaluation</p><h2>실행 결과</h2></div><span class="result-chip" id="result-badge">아직 실행하지 않음</span></div>
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
<div class="compact-section-heading"><h3>측정 요약</h3><span>peak · 오차 · beam</span></div>
<div class="empty-result" id="empty-result">실행 후 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
<div class="metric-grid is-hidden" id="metric-grid"></div>
</section>
<section class="history-section">
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div></div>
<p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p>
<details class="history-section utility-details" id="comparison-history">
<summary><span>배열 비교 기록</span><small>같은 설정의 재실행 결과</small></summary>
<div class="history-body">
<div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
<p class="section-copy">배열을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 비교합니다.</p>
<div class="history-list" id="history-list"></div>
</section>
</section>
<aside class="control-panel" aria-label="Simulation parameters">
<section class="array-card">
<div class="array-heading"><div><p class="eyebrow">Microphone geometry</p><h2 id="array-title">배열</h2></div><strong id="microphone-count">— ch</strong></div>
<label class="array-preset-field"><span>비교 배열</span><select id="array-preset"><option value="rectangular-16x12">16×12 rectangular · 기준</option><option value="fermat-spiral-192">Fermat spiral · 비교</option><option value="custom">불러온 Array JSON</option></select></label>
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
<p id="array-meta">배열을 불러오고 있습니다.</p>
</section>
<section class="asset-card">
<div class="section-title"><span>INPUT</span><h2>장면과 assets</h2></div>
<div class="source-summary" id="source-summary"></div>
<div class="asset-list" id="asset-list"></div>
<a class="builder-link" href="/apps/dataset-builder/">음원 위치·ambient 편집은 Dataset Builder에서 →</a>
</section>
<section class="control-section">
<div class="section-title"><span>01</span><h2>실험</h2></div>
<label class="field field--wide"><span>이름</span><input id="experiment-name" type="text" maxlength="120" /></label>
<label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label>
<div class="field-grid">
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
</div>
</section>
</details>
<section class="control-section">
<div class="section-title"><span>02</span><h2>Scan grid</h2></div>
<label class="field field--wide"><span>Preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
<div class="field-grid">
<label class="field"><span>Columns</span><input id="scan-columns" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>Rows</span><input id="scan-rows" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>x min (m)</span><input id="scan-x-min" type="number" step="0.05" /></label>
<label class="field"><span>x max (m)</span><input id="scan-x-max" type="number" step="0.05" /></label>
<label class="field"><span>y min (m)</span><input id="scan-y-min" type="number" step="0.05" /></label>
<label class="field"><span>y max (m)</span><input id="scan-y-max" type="number" step="0.05" /></label>
<details class="help-panel utility-details" id="camera-help">
<summary><span>표시 해석과 한계</span><small>DAS · beam spread · live</small></summary>
<div class="help-grid">
<p><strong>Heatmap</strong>DAS 공간 power이며 위치 확률분포가 아닙니다.</p>
<p><strong>빔 분포</strong>Half-power covariance 폭이며 신뢰구간이 아닙니다.</p>
<p><strong>이동 재생</strong>준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.</p>
</div>
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
</details>
</section>
<section class="control-section">
<div class="section-title"><span>03</span><h2>STFT와 표시</h2></div>
<div class="field-grid">
<label class="field"><span>FFT size</span><select id="fft-size"><option>128</option><option>256</option><option selected>512</option><option>1024</option><option>2048</option><option>4096</option></select></label>
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 검출 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
</div>
</section>
<section class="control-section">
<div class="section-title section-title--spread"><div><span>04</span><h2>주파수 대역</h2></div><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
<div class="band-editor-list" id="band-list"></div>
</section>
<section class="output-card">
<div class="output-actions">
<button class="button button--ghost" id="export-scene-button" type="button">Scene JSON 저장</button>
<button class="button button--ghost" id="export-experiment-button" type="button">Experiment JSON 저장</button>
</div>
<p class="output-save-state" id="output-save-state">저장하지 않은 변경 없음</p>
</section>
<section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div>
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">입력을 준비하고 있습니다.</p></div>
</section>
</aside>
</main>
</div>
<script type="module" src="/packages/ui/runtime-status.js"></script>
+411 -132
View File
@@ -1,148 +1,427 @@
.camera-shell { min-width: 320px; }
.camera-header { position: sticky; z-index: 30; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; }
.header-status, .header-actions, .workspace-tools, .overlay-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; min-width: 0; }
.header-status { justify-content: center; }
.run-status { overflow: hidden; max-width: 420px; color: var(--muted); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 9px 14px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface-raised); cursor: pointer; font-size: .8rem; font-weight: 720; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; }
.button:hover, .button:focus-visible, .mini-button:hover, .mini-button:focus-visible { border-color: var(--mint); outline: none; transform: translateY(-1px); }
.button:disabled { cursor: wait; opacity: .48; transform: none; }
.button--primary { border-color: rgb(101 242 191 / 55%); color: #06130f; background: var(--mint); }
.camera-header {
position: sticky;
z-index: 50;
top: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 54px;
padding: 8px clamp(14px, 2vw, 26px);
gap: 14px;
}
.header-status,
.header-actions,
.live-actions,
.overlay-toolbar,
.history-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.header-status { justify-content: flex-end; }
.header-actions { justify-content: flex-end; }
.run-status {
overflow: hidden;
max-width: min(42vw, 520px);
color: var(--muted);
font-size: .7rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.camera-header .status-chip { min-height: 28px; padding: 4px 9px; font-size: .68rem; }
.button {
display: inline-flex;
min-height: 34px;
align-items: center;
justify-content: center;
padding: 7px 11px;
border: 1px solid var(--line);
border-radius: 9px;
color: var(--text);
background: var(--surface-raised);
cursor: pointer;
font-size: .74rem;
font-weight: 720;
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
}
.button:hover,
.button:focus-visible,
.mini-button:hover,
.mini-button:focus-visible,
.file-choice:hover,
.file-choice:focus-within { border-color: var(--mint); outline: none; }
.button:hover:not(:disabled), .mini-button:hover:not(:disabled) { transform: translateY(-1px); }
.button:disabled { cursor: not-allowed; opacity: .45; transform: none; }
.button--primary { border-color: rgb(101 242 191 / 52%); color: #06130f; background: var(--mint); }
.button--ghost { background: rgb(255 255 255 / 3%); }
.button--full { width: 100%; }
.file-button input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
.camera-layout { display: grid; grid-template-columns: minmax(600px, 1fr) minmax(350px, 410px); min-height: calc(100vh - 65px); }
.scene-workspace { min-width: 0; padding: clamp(22px, 3vw, 42px); }
.workspace-heading, .section-heading, .array-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
.workspace-heading { margin-bottom: 20px; }
.workspace-heading .eyebrow, .section-heading .eyebrow, .array-heading .eyebrow { margin-bottom: 7px; }
.workspace-heading h1 { margin-bottom: 4px; font-size: clamp(1.9rem, 4vw, 3.2rem); letter-spacing: -.055em; }
.workspace-meta, .section-copy { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.55; }
.workspace-import { display: grid; gap: 7px; justify-items: end; }
.workspace-tools { justify-content: flex-end; }
.import-order { margin: 0; color: var(--muted); font-size: .64rem; }
.scene-canvas-card { position: relative; overflow: hidden; min-height: 520px; border: 1px solid var(--line); border-radius: 22px; background: #07110f; box-shadow: var(--shadow); }
#scene-canvas { display: block; width: 100%; height: clamp(520px, 64vh, 760px); }
.canvas-message { position: absolute; top: 32px; left: 50%; display: grid; width: min(440px, calc(100% - 48px)); gap: 6px; color: var(--muted); text-align: center; pointer-events: none; transform: translateX(-50%); }
.canvas-message strong { color: #cfe2db; font-size: .9rem; }
.canvas-message span { font-size: .74rem; }
.canvas-message.is-hidden { display: none; }
.canvas-badge { position: absolute; right: 14px; bottom: 14px; max-width: calc(100% - 28px); overflow: hidden; padding: 7px 10px; border: 1px solid rgb(255 255 255 / 10%); border-radius: 999px; color: #d7e8e2; background: rgb(8 17 15 / 80%); backdrop-filter: blur(10px); font: 650 .7rem ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
.overlay-toolbar { justify-content: space-between; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; background: rgb(255 255 255 / 2%); }
.band-legend { display: flex; flex: 1 1 360px; flex-wrap: wrap; gap: 7px; }
.legend-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0b1714; cursor: pointer; font-size: .68rem; }
.legend-toggle input { accent-color: var(--band-color, var(--mint)); }
.legend-swatch { width: 9px; height: 9px; border-radius: 50%; background: var(--band-color, var(--mint)); }
.range-field, .check-field { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .7rem; font-weight: 650; }
.range-field input { width: 110px; accent-color: var(--mint); }
.check-field input { width: 17px; height: 17px; accent-color: var(--mint); }
.live-panel { margin-top: 12px; padding: 15px 16px; border: 1px solid rgb(88 199 255 / 24%); border-radius: 16px; background: linear-gradient(135deg, rgb(88 199 255 / 7%), rgb(101 242 191 / 3%)); }
.live-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.live-heading h2 { margin: 0; font-size: .98rem; }
.live-heading p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); font-size: .7rem; }
.live-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.live-controls { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 12px; align-items: end; margin-top: 14px; }
.live-scrubber, .compact-select { display: grid; gap: 6px; color: var(--muted); font-size: .66rem; font-weight: 650; }
.live-scrubber input { width: 100%; accent-color: var(--cyan); }
.compact-select select { min-height: 34px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
.live-note { margin: 10px 0 0; color: var(--muted); font-size: .64rem; line-height: 1.55; }
.live-panel.is-ready { border-color: rgb(101 242 191 / 42%); }
.live-panel.is-playing { box-shadow: inset 3px 0 0 var(--cyan); }
.result-section, .history-section { margin-top: 28px; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: rgb(255 255 255 / 2%); }
.section-heading h2 { margin: 0; font-size: 1.05rem; }
.result-chip { padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .68rem; }
.empty-result { margin-top: 14px; padding: 18px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); font-size: .78rem; line-height: 1.6; text-align: center; }
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; margin-top: 14px; }
.metric-card { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: #0b1714; }
.metric-card span { display: block; margin-bottom: 7px; color: var(--muted); font-size: .66rem; }
.metric-card strong { display: block; overflow: hidden; font-size: 1.15rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.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; }
.history-card strong { font-size: .78rem; }
.history-card span { color: var(--muted); font-size: .67rem; }
.control-panel { position: sticky; top: 65px; align-self: start; overflow-y: auto; max-height: calc(100vh - 65px); padding: 16px; border-left: 1px solid var(--line); background: rgb(12 24 21 / 90%); backdrop-filter: blur(18px); }
.array-card, .asset-card, .control-section, .output-card, .validation-card { padding: 16px; border: 1px solid var(--line); border-radius: 16px; background: rgb(255 255 255 / 2%); }
.array-card + section, .control-panel section + section { margin-top: 11px; }
.array-heading { align-items: flex-start; }
.array-heading h2 { margin: 0; font-size: .95rem; }
.array-heading > strong { color: var(--mint); font: 750 .78rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.array-preset-field { display: grid; gap: 6px; margin-top: 12px; }
.array-preset-field span { color: var(--muted); font-size: .66rem; font-weight: 650; }
.array-preset-field select { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
.array-preset-field select:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); }
#array-canvas { display: block; width: 100%; height: 265px; margin-top: 10px; border: 1px solid var(--line); border-radius: 12px; background: #07110f; }
#array-meta { margin: 9px 0 0; color: var(--muted); font: 600 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.section-title { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; }
.section-title > span, .section-title > div > span { color: var(--mint); font: 750 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.section-title h2 { margin: 0; font-size: .88rem; }
.section-title--spread { justify-content: space-between; }
.section-title--spread > div { display: flex; align-items: center; gap: 9px; }
.source-summary { display: grid; gap: 7px; }
.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .69rem; }
.workflow-strip {
position: sticky;
z-index: 40;
top: 54px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
padding: 0 clamp(14px, 2vw, 26px);
border-bottom: 1px solid var(--line);
background: rgb(8 17 15 / 94%);
backdrop-filter: blur(18px);
}
.workflow-strip a {
display: grid;
grid-template-columns: 24px auto;
grid-template-rows: auto auto;
min-width: 0;
padding: 9px 12px;
border-right: 1px solid rgb(145 170 162 / 10%);
color: var(--text);
text-decoration: none;
}
.workflow-strip a:first-child { border-left: 1px solid rgb(145 170 162 / 10%); }
.workflow-strip a:hover, .workflow-strip a:focus-visible { background: rgb(101 242 191 / 5%); outline: none; }
.workflow-strip a > span {
grid-row: 1 / 3;
align-self: center;
width: 20px;
height: 20px;
border: 1px solid rgb(101 242 191 / 35%);
border-radius: 50%;
color: var(--mint);
font: 750 .62rem/18px ui-monospace, SFMono-Regular, Consolas, monospace;
text-align: center;
}
.workflow-strip strong { overflow: hidden; font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
.workflow-strip small { overflow: hidden; color: var(--muted); font-size: .58rem; text-overflow: ellipsis; white-space: nowrap; }
.camera-layout {
display: grid;
grid-template-columns: minmax(320px, 360px) minmax(620px, 1fr);
width: min(1880px, 100%);
min-height: calc(100vh - 103px);
margin: 0 auto;
}
.control-panel {
position: sticky;
top: 103px;
align-self: start;
overflow-y: auto;
max-height: calc(100vh - 103px);
padding: 12px;
border-right: 1px solid var(--line);
background: rgb(12 24 21 / 88%);
backdrop-filter: blur(18px);
scrollbar-width: thin;
}
.control-panel .workflow-card {
margin: 0;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgb(255 255 255 / 2%);
}
.control-panel .workflow-card + .workflow-card,
.control-panel .workflow-card + .validation-card { margin-top: 10px; }
.step-heading { display: flex; align-items: flex-start; gap: 10px; }
.step-heading--horizontal { align-items: center; }
.step-number {
display: inline-grid;
width: 24px;
height: 24px;
flex: 0 0 auto;
place-items: center;
border-radius: 7px;
color: #06130f;
background: var(--mint);
font: 800 .67rem ui-monospace, SFMono-Regular, Consolas, monospace;
}
.step-heading h2 { margin: 0; font-size: .9rem; letter-spacing: -.02em; }
.step-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; line-height: 1.45; }
.workspace-tools { display: grid; }
.input-file-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 12px; }
.file-choice {
position: relative;
display: grid;
grid-template-columns: 19px minmax(0, 1fr);
align-items: center;
min-height: 48px;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: #0b1714;
cursor: pointer;
}
.file-choice input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
.file-sequence { color: var(--mint); font: 700 .58rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.file-choice strong, .file-choice small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-choice strong { font-size: .68rem; }
.file-choice small { margin-top: 2px; color: var(--muted); font-size: .56rem; }
.input-summary, .array-card, .band-config { margin-top: 12px; padding-top: 11px; border-top: 1px solid rgb(145 170 162 / 13%); }
.compact-section-heading, .array-heading, .inspect-heading, .mode-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.compact-section-heading h3, .array-heading h3 { margin: 0; font-size: .75rem; }
.compact-section-heading > span { color: var(--muted); font-size: .61rem; }
.compact-label, .mode-kicker { color: var(--mint); font: 750 .56rem ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; }
.builder-link { color: var(--cyan); font-size: .6rem; text-decoration: none; }
.source-summary, .asset-list { display: grid; gap: 6px; margin-top: 8px; }
.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .63rem; }
.source-line strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.asset-list { display: grid; gap: 7px; margin-top: 10px; }
.asset-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); font-size: .66rem; }
.asset-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--muted);
font-size: .61rem;
}
.asset-status strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.asset-status.is-ready span { color: var(--mint); }
.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; }
.field input:focus, .field select:focus, .band-editor input:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); }
.field--wide + .field--wide, .field--wide + .field-grid { margin-top: 9px; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 9px; }
.workload-summary { margin: 11px 0 0; padding: 10px; border-radius: 9px; color: var(--muted); background: #0b1714; font: 600 .66rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
.workload-summary.is-warning { color: var(--amber); }
.band-editor-list { display: grid; gap: 9px; }
.band-editor { display: grid; grid-template-columns: minmax(70px, 1fr) minmax(62px, .8fr) minmax(62px, .8fr) 34px; gap: 6px; padding: 9px; border: 1px solid var(--line); border-radius: 11px; background: #0b1714; }
.band-editor input { min-width: 0; font-size: .68rem; }
.band-editor input[type="color"] { padding: 3px; }
.band-editor__remove { border: 0; border-radius: 8px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
.mini-button { border-color: var(--line); }
.validation-card { display: flex; align-items: flex-start; gap: 11px; }
.validation-dot { width: 10px; height: 10px; flex: 0 0 auto; margin-top: 4px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 5px rgb(145 170 162 / 8%); }
.validation-card strong { font-size: .8rem; }
.validation-card p { margin: 4px 0 0; color: var(--muted); font-size: .69rem; line-height: 1.5; }
.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 5px rgb(101 242 191 / 10%); }
.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 5px rgb(255 123 115 / 10%); }
.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 5px rgb(255 197 110 / 10%); }
.is-hidden { display: none !important; }
@media (max-width: 1120px) {
.camera-header { grid-template-columns: auto 1fr; }
.header-status { justify-content: flex-end; }
.header-actions { grid-column: 1 / -1; justify-content: flex-end; }
.camera-layout { grid-template-columns: 1fr; }
.control-panel { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; }
.asset-empty { color: var(--muted); font-size: .62rem; }
.array-heading > strong { color: var(--mint); font: 750 .68rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.field { display: grid; min-width: 0; gap: 5px; }
.field > span { color: var(--muted); font-size: .61rem; font-weight: 650; }
.field input, .field select, .band-editor input, .compact-select select {
width: 100%;
min-height: 34px;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: #0b1714;
font-size: .68rem;
}
@media (max-width: 760px) {
.camera-header, .workspace-heading { align-items: stretch; }
.camera-header { position: static; display: flex; flex-direction: column; }
.header-status, .header-actions, .workspace-tools { justify-content: flex-start; width: 100%; }
.workspace-import { justify-items: start; }
.header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); }
.scene-workspace { padding: 18px; }
.workspace-heading { flex-direction: column; }
.live-heading { align-items: stretch; flex-direction: column; }
.field input:focus, .field select:focus, .band-editor input:focus, .compact-select select:focus {
border-color: var(--mint);
outline: 2px solid rgb(101 242 191 / 10%);
}
.array-card > .field { margin-top: 9px; }
.quick-field-grid, .advanced-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.quick-field-grid { margin-top: 12px; }
.field--wide { grid-column: 1 / -1; }
.workload-summary {
margin: 9px 0 0;
padding: 8px;
border-radius: 8px;
color: var(--muted);
background: #0b1714;
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
}
.workload-summary.is-warning { color: var(--amber); }
.mini-button, .text-button {
padding: 5px 7px;
border: 1px solid transparent;
border-radius: 7px;
color: var(--muted);
background: transparent;
cursor: pointer;
font-size: .62rem;
}
.mini-button { border-color: var(--line); }
.band-editor-list { display: grid; gap: 7px; margin-top: 8px; }
.band-editor {
display: grid;
grid-template-columns: minmax(66px, 1fr) minmax(55px, .72fr) minmax(55px, .72fr) 30px;
gap: 5px;
padding: 7px;
border: 1px solid var(--line);
border-radius: 9px;
background: #0b1714;
}
.band-editor input { min-width: 0; }
.band-editor input[type="color"] { padding: 3px; }
.band-editor__remove { border: 0; border-radius: 7px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
details > summary { list-style: none; }
details > summary::-webkit-details-marker { display: none; }
.inline-details, .advanced-settings, .utility-details, .mode-help {
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: rgb(255 255 255 / 1.5%);
}
.inline-details > summary, .advanced-settings > summary, .utility-details > summary, .mode-help > summary {
position: relative;
padding: 9px 30px 9px 10px;
color: var(--muted);
cursor: pointer;
font-size: .64rem;
font-weight: 680;
}
.inline-details > summary::after, .advanced-settings > summary::after, .utility-details > summary::after, .mode-help > summary::after {
position: absolute;
top: 50%;
right: 10px;
color: var(--mint);
content: "+";
transform: translateY(-50%);
}
details[open] > summary::after { content: "−"; }
.advanced-settings > summary { display: flex; justify-content: space-between; gap: 10px; }
.advanced-settings > summary small, .utility-details > summary small { color: var(--muted); font-size: .57rem; font-weight: 500; }
.advanced-settings__body { display: grid; gap: 14px; padding: 11px; border-top: 1px solid var(--line); }
.advanced-group h3 { margin: 0 0 8px; color: #cfe2db; font-size: .66rem; }
#array-canvas { display: block; width: 100%; height: 180px; border-top: 1px solid var(--line); background: #07110f; }
#array-meta { margin: 0; padding: 8px; color: var(--muted); font: 600 .58rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.output-card { padding: 0; }
.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 10px 10px 0; border-top: 1px solid var(--line); }
.output-save-state { margin: 7px 0 0; padding: 0 10px 10px; color: var(--muted); font-size: .6rem; text-align: center; }
.output-save-state.is-dirty { color: var(--amber); }
.validation-card {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 10px;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #0b1714;
}
.validation-dot { width: 9px; height: 9px; flex: 0 0 auto; margin-top: 3px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 4px rgb(145 170 162 / 8%); }
.validation-card strong { font-size: .7rem; }
.validation-card p { margin: 3px 0 0; color: var(--muted); font-size: .62rem; line-height: 1.45; }
.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 4px rgb(101 242 191 / 10%); }
.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 4px rgb(255 123 115 / 10%); }
.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 4px rgb(255 197 110 / 10%); }
.scene-workspace { min-width: 0; padding: 16px clamp(16px, 2vw, 28px) 36px; }
.workspace-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.workspace-heading h1 { margin: 2px 0 0; font-size: clamp(1.25rem, 2vw, 1.65rem); letter-spacing: -.035em; }
.workspace-meta { max-width: 64%; margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.45; text-align: right; }
.run-stage { padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: rgb(255 255 255 / 2%); }
.mode-grid { display: grid; grid-template-columns: minmax(210px, .72fr) minmax(430px, 1.28fr); gap: 9px; margin-top: 11px; }
.mode-card { min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
.mode-card--snapshot { display: flex; flex-direction: column; border-color: rgb(101 242 191 / 22%); }
.mode-card--moving { border-color: rgb(88 199 232 / 24%); }
.mode-heading { align-items: flex-start; }
.mode-heading h3 { margin: 2px 0 0; font-size: .88rem; }
.mode-card > p { margin: 7px 0 10px; color: var(--muted); font-size: .64rem; line-height: 1.45; }
.mode-format { padding: 4px 7px; border-radius: 999px; color: var(--muted); background: rgb(255 255 255 / 4%); font-size: .57rem; }
.mode-actions { margin-top: auto; }
.mode-card--snapshot .mode-actions { padding-top: 8px; }
.live-actions { justify-content: flex-start; }
.live-controls { display: grid; grid-template-columns: minmax(150px, 1fr) 74px 64px; align-items: end; gap: 8px; margin-top: 9px; }
.live-scrubber, .compact-select { display: grid; gap: 5px; color: var(--muted); font-size: .59rem; font-weight: 650; }
.live-scrubber input { width: 100%; accent-color: var(--cyan); }
.mode-help { margin-top: 8px; border-color: rgb(88 199 232 / 15%); }
.mode-help > summary { padding-top: 7px; padding-bottom: 7px; font-size: .58rem; }
.live-note { margin: 0; padding: 0 9px 9px; color: var(--muted); font-size: .59rem; line-height: 1.5; }
.live-panel.is-ready { border-color: rgb(101 242 191 / 48%); }
.live-panel.is-playing { border-color: var(--cyan); box-shadow: inset 3px 0 0 var(--cyan); }
html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-shadow: inset 3px 0 0 var(--mint); }
.inspect-stage { margin-top: 14px; }
.inspect-heading { align-items: center; margin-bottom: 10px; }
.result-chip { max-width: 46%; overflow: hidden; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .62rem; text-overflow: ellipsis; white-space: nowrap; }
.scene-canvas-card { position: relative; overflow: hidden; min-height: 500px; border: 1px solid var(--line); border-radius: 16px; background: #07110f; box-shadow: var(--shadow); }
#scene-canvas { display: block; width: 100%; height: clamp(500px, 66vh, 820px); }
.canvas-message { position: absolute; top: 26px; left: 50%; display: grid; width: min(390px, calc(100% - 40px)); gap: 5px; color: var(--muted); text-align: center; pointer-events: none; transform: translateX(-50%); }
.canvas-message strong { color: #cfe2db; font-size: .8rem; }
.canvas-message span { font-size: .65rem; }
.canvas-message.is-hidden { display: none; }
.canvas-badge { position: absolute; right: 12px; bottom: 12px; max-width: calc(100% - 24px); overflow: hidden; padding: 6px 9px; border: 1px solid rgb(255 255 255 / 10%); border-radius: 999px; color: #d7e8e2; background: rgb(8 17 15 / 82%); backdrop-filter: blur(10px); font: 650 .64rem ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
.overlay-toolbar { justify-content: flex-end; margin-top: 8px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 11px; background: rgb(255 255 255 / 2%); }
.band-legend { display: flex; flex: 1 1 300px; flex-wrap: wrap; gap: 6px; }
.legend-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0b1714; cursor: pointer; font-size: .62rem; }
.legend-toggle input { accent-color: var(--band-color, var(--mint)); }
.legend-swatch { width: 8px; height: 8px; border-radius: 50%; background: var(--band-color, var(--mint)); }
.range-field, .check-field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .63rem; font-weight: 650; }
.range-field input { width: 92px; accent-color: var(--mint); }
.check-field input { width: 15px; height: 15px; accent-color: var(--mint); }
.result-section { margin-top: 10px; padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: rgb(255 255 255 / 2%); }
.empty-result { margin-top: 9px; padding: 12px; border: 1px dashed var(--line); border-radius: 9px; color: var(--muted); font-size: .68rem; line-height: 1.5; text-align: center; }
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); gap: 8px; margin-top: 9px; }
.metric-card { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: #0b1714; }
.metric-card span { display: block; margin-bottom: 5px; color: var(--muted); font-size: .6rem; }
.metric-card strong { display: block; overflow: hidden; font-size: 1rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.metric-card small { display: block; margin-top: 4px; color: var(--muted); font-size: .57rem; }
.history-section, .help-panel { margin-top: 9px; border-radius: 11px; }
.history-section > summary, .help-panel > summary { display: flex; justify-content: space-between; gap: 12px; padding: 10px 34px 10px 11px; }
.history-body { padding: 0 11px 11px; border-top: 1px solid var(--line); }
.history-actions { justify-content: flex-end; padding-top: 8px; }
.section-copy { margin: 5px 0 0; color: var(--muted); font-size: .62rem; line-height: 1.45; }
.history-list { display: grid; gap: 7px; margin-top: 9px; }
.history-empty { padding: 10px; color: var(--muted); font-size: .65rem; text-align: center; }
.history-card { display: grid; grid-template-columns: minmax(140px, 1.5fr) repeat(4, minmax(72px, .72fr)); gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: #0b1714; }
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-card strong { font-size: .69rem; }
.history-card span { color: var(--muted); font-size: .6rem; }
.help-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
.help-grid p { margin: 0; padding: 9px; border-radius: 8px; color: var(--muted); background: #0b1714; font-size: .61rem; line-height: 1.45; }
.help-grid strong { display: block; margin-bottom: 3px; color: var(--text); font-size: .63rem; }
.is-hidden { display: none !important; }
@media (max-width: 1240px) {
.camera-layout { grid-template-columns: minmax(300px, 330px) minmax(560px, 1fr); }
.mode-grid { grid-template-columns: 1fr; }
.mode-card--snapshot { display: grid; grid-template-columns: 1fr auto; gap: 0 12px; }
.mode-card--snapshot > p { grid-column: 1; }
.mode-card--snapshot .mode-actions { grid-column: 2; grid-row: 1 / 3; align-self: center; min-width: 160px; padding: 0; }
}
@media (max-width: 980px) {
.camera-layout { grid-template-columns: minmax(0, 1fr); }
.control-panel, .scene-workspace { width: 100%; min-width: 0; }
.control-panel { position: static; overflow: visible; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); }
.scene-workspace { padding-top: 14px; }
.control-panel .workflow-card { max-width: none; }
.input-card { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.input-card > .step-heading, .input-card > .input-file-grid { grid-column: 1 / -1; }
.input-summary, .array-card { margin-top: 12px; }
}
@media (max-width: 700px) {
.camera-header { grid-template-columns: 1fr auto; }
.header-status { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
.run-status { max-width: calc(100vw - 150px); }
.workflow-strip { top: 92px; padding: 0; }
.workflow-strip a { min-width: 0; padding: 8px; }
.workflow-strip small { display: none; }
.camera-layout { min-height: calc(100vh - 137px); }
.input-card { display: block; }
.workspace-heading, .inspect-heading { align-items: flex-start; flex-direction: column; }
.workspace-meta { max-width: none; text-align: left; }
.result-chip { max-width: 100%; }
.mode-card--snapshot { display: flex; }
.live-controls { grid-template-columns: 1fr 1fr; }
.live-scrubber { grid-column: 1 / -1; }
.scene-canvas-card { min-height: 380px; }
#scene-canvas { height: 420px; }
.scene-canvas-card { min-height: 400px; }
#scene-canvas { height: 460px; }
.help-grid { grid-template-columns: 1fr; }
.history-card { grid-template-columns: 1fr 1fr; }
.history-card strong { grid-column: 1 / -1; }
}
@media (max-width: 430px) {
.field-grid, .band-editor, .output-actions, .live-controls { grid-template-columns: 1fr; }
.live-scrubber { grid-column: auto; }
.band-editor__remove { min-height: 34px; }
@media (max-width: 440px) {
.camera-header .app-brand span, .camera-header .status-chip { display: none; }
.header-status { grid-column: 1 / -1; }
.run-status { max-width: 100%; }
.workflow-strip { top: 88px; }
.input-file-grid, .quick-field-grid, .advanced-field-grid, .output-actions, .band-editor, .live-controls { grid-template-columns: 1fr; }
.field--wide, .live-scrubber { grid-column: auto; }
.band-editor__remove { min-height: 32px; }
.live-actions .button { flex: 1 1 calc(50% - 4px); }
.metric-grid { grid-template-columns: 1fr 1fr; }
.overlay-toolbar { justify-content: flex-start; }
}
+30
View File
@@ -60,9 +60,27 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
"기본 Camera 입력 검증",
);
const workflowSteps = [...documentObject.querySelectorAll("#camera-workflow [data-workflow-step]")];
assertEqual(workflowSteps.length, 4, "four-step workflow is discoverable");
assertEqual(
workflowSteps.map((step) => step.dataset.workflowStep).join(","),
"1,2,3,4",
"workflow order",
);
assert(documentObject.querySelector("#input-step").textContent.includes("입력 준비"), "input step label");
assert(documentObject.querySelector("#configure-step").textContent.includes("분석 설정"), "configure step label");
assert(documentObject.querySelector("#run-step").textContent.includes("실행 방식"), "run step label");
assert(documentObject.querySelector("#inspect-step").textContent.includes("결과 확인"), "inspect step label");
assert(documentObject.querySelector("#snapshot-mode").contains(documentObject.querySelector("#run-button")), "snapshot action is grouped with snapshot mode");
assert(documentObject.querySelector("#moving-mode").contains(documentObject.querySelector("#prepare-live-button")), "live action is grouped with moving mode");
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
const canvas = documentObject.querySelector("#scene-canvas");
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
await nextPaint(windowObject);
assert(canvas.getBoundingClientRect().height >= 500, "scene canvas remains the dominant workspace");
const initialCanvas = canvas.toDataURL();
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
@@ -208,6 +226,18 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
"final Camera documents saved",
);
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
frame.style.width = "390px";
await nextPaint(windowObject);
assert(
documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1,
"compact Camera layout has no horizontal overflow",
);
const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect();
assert(
finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1,
"all workflow steps remain visible on a compact viewport",
);
} finally {
windowObject.confirm = originalConfirm;
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;