feat: build reproducible dataset editor
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { estimateSceneMix, MAX_MIX_WORKING_BYTES } from "/packages/acoustics-core/mixdown.js";
|
||||
|
||||
export function createAudioRuntime() {
|
||||
let worker = null;
|
||||
let rejectWorker = null;
|
||||
let playbackContext = null;
|
||||
let playbackSource = null;
|
||||
|
||||
function cancelMix() {
|
||||
if (!worker) return false;
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
const reject = rejectWorker;
|
||||
rejectWorker = null;
|
||||
reject?.(new DOMException("사용자가 mix 생성을 취소했습니다.", "AbortError"));
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderMix(scene, assetBuffers) {
|
||||
if (worker) throw new Error("이미 확인용 mix를 만들고 있습니다.");
|
||||
const estimate = estimateSceneMix(scene, assetBuffers);
|
||||
const crossThreadBytes = estimate.workingBytes + estimate.assetBytes;
|
||||
if (crossThreadBytes > MAX_MIX_WORKING_BYTES) {
|
||||
throw new Error(
|
||||
`decode buffer와 Worker 복사본을 포함한 예상 메모리 ${(crossThreadBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`${(MAX_MIX_WORKING_BYTES / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다.`,
|
||||
);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const currentWorker = new Worker("/apps/dataset-builder/mix-worker.js", { type: "module" });
|
||||
worker = currentWorker;
|
||||
rejectWorker = reject;
|
||||
currentWorker.addEventListener("message", ({ data }) => {
|
||||
if (worker !== currentWorker) return;
|
||||
worker = null;
|
||||
rejectWorker = null;
|
||||
currentWorker.terminate();
|
||||
if (data.ok) resolve(data.result);
|
||||
else reject(new Error(data.message));
|
||||
});
|
||||
currentWorker.addEventListener("error", (event) => {
|
||||
if (worker !== currentWorker) return;
|
||||
worker = null;
|
||||
rejectWorker = null;
|
||||
currentWorker.terminate();
|
||||
reject(new Error(event.message || "mix Worker가 중단되었습니다."));
|
||||
});
|
||||
currentWorker.postMessage({ scene, assetBuffers: [...assetBuffers] });
|
||||
});
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (!playbackSource) return false;
|
||||
playbackSource.onended = null;
|
||||
try {
|
||||
playbackSource.stop();
|
||||
} catch {
|
||||
// 이미 끝난 source는 정리만 한다.
|
||||
}
|
||||
playbackSource.disconnect();
|
||||
playbackSource = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function play(result, onEnded) {
|
||||
stopPlayback();
|
||||
playbackContext ??= new AudioContext();
|
||||
await playbackContext.resume();
|
||||
const buffer = playbackContext.createBuffer(1, result.samples.length, result.sampleRate);
|
||||
buffer.copyToChannel(result.samples, 0);
|
||||
playbackSource = playbackContext.createBufferSource();
|
||||
playbackSource.buffer = buffer;
|
||||
playbackSource.connect(playbackContext.destination);
|
||||
playbackSource.onended = () => {
|
||||
playbackSource?.disconnect();
|
||||
playbackSource = null;
|
||||
onEnded?.();
|
||||
};
|
||||
playbackSource.start();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
cancelMix();
|
||||
stopPlayback();
|
||||
playbackContext?.close();
|
||||
playbackContext = null;
|
||||
}
|
||||
|
||||
return {
|
||||
cancelMix,
|
||||
dispose,
|
||||
get isMixing() {
|
||||
return Boolean(worker);
|
||||
},
|
||||
get isPlaying() {
|
||||
return Boolean(playbackSource);
|
||||
},
|
||||
play,
|
||||
renderMix,
|
||||
stopPlayback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { SOURCE_COLORS } from "./model.js";
|
||||
|
||||
function finitePlane(plane) {
|
||||
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) &&
|
||||
plane.xMin < plane.xMax && plane.yMin < plane.yMax;
|
||||
}
|
||||
|
||||
export function coordinateToPixel(position, plane, width, height) {
|
||||
return {
|
||||
x: ((position[0] - plane.xMin) / (plane.xMax - plane.xMin)) * width,
|
||||
y: ((plane.yMax - position[1]) / (plane.yMax - plane.yMin)) * height,
|
||||
};
|
||||
}
|
||||
|
||||
export function pixelToCoordinate(point, plane, width, height) {
|
||||
return {
|
||||
x: plane.xMin + (point.x / width) * (plane.xMax - plane.xMin),
|
||||
y: plane.yMax - (point.y / height) * (plane.yMax - plane.yMin),
|
||||
};
|
||||
}
|
||||
|
||||
function drawGrid(context, width, height, plane) {
|
||||
context.fillStyle = "#0a1512";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.lineWidth = 1;
|
||||
for (let index = 0; index <= 10; index += 1) {
|
||||
const x = (index / 10) * width;
|
||||
const y = (index / 10) * height;
|
||||
context.strokeStyle = index === 5 ? "rgba(101,242,191,.17)" : "rgba(145,170,162,.08)";
|
||||
context.beginPath();
|
||||
context.moveTo(x, 0);
|
||||
context.lineTo(x, height);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(0, y);
|
||||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
context.fillStyle = "rgba(205,225,218,.52)";
|
||||
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||
context.fillText(`x ${plane.xMin.toFixed(2)} m`, 14, height - 16);
|
||||
context.textAlign = "right";
|
||||
context.fillText(`${plane.xMax.toFixed(2)} m`, width - 14, height - 16);
|
||||
context.textAlign = "left";
|
||||
}
|
||||
|
||||
function drawSource(context, source, index, selected, plane, width, height) {
|
||||
if (!source.position?.every(Number.isFinite)) return;
|
||||
const point = coordinateToPixel(source.position, plane, width, height);
|
||||
if (point.x < -30 || point.x > width + 30 || point.y < -30 || point.y > height + 30) return;
|
||||
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
|
||||
context.save();
|
||||
context.shadowColor = color;
|
||||
context.shadowBlur = selected ? 22 : 12;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, selected ? 10 : 8, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.shadowBlur = 0;
|
||||
if (selected) {
|
||||
context.strokeStyle = "rgba(255,255,255,.9)";
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, 16, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
const label = `${index + 1} · ${source.name}`;
|
||||
context.font = "700 12px Inter, system-ui, sans-serif";
|
||||
const labelWidth = context.measureText(label).width + 18;
|
||||
const labelX = Math.max(8, Math.min(width - labelWidth - 8, point.x + 18));
|
||||
const labelY = Math.max(26, Math.min(height - 10, point.y - 12));
|
||||
context.fillStyle = "rgba(6,15,12,.88)";
|
||||
context.fillRect(labelX, labelY - 18, labelWidth, 27);
|
||||
context.fillStyle = "#eef8f4";
|
||||
context.fillText(label, labelX + 9, labelY);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
|
||||
const context = canvas.getContext("2d");
|
||||
let current = null;
|
||||
|
||||
function render(scene, image, plane, selectedSourceId) {
|
||||
current = { scene, image, plane, selectedSourceId };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width);
|
||||
const height = Math.max(1, rect.height);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const targetWidth = Math.round(width * pixelRatio);
|
||||
const targetHeight = Math.round(height * pixelRatio);
|
||||
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
}
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
if (!finitePlane(plane)) {
|
||||
context.fillStyle = "#0a1512";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = "#ff7b73";
|
||||
context.font = "700 14px Inter, system-ui, sans-serif";
|
||||
context.fillText("표시 평면 범위를 확인하세요.", 20, 30);
|
||||
return;
|
||||
}
|
||||
drawGrid(context, width, height, plane);
|
||||
if (image) {
|
||||
context.globalAlpha = 0.7;
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = "rgba(6,15,12,.12)";
|
||||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
scene.sources.forEach((source, index) => {
|
||||
drawSource(context, source, index, source.id === selectedSourceId, plane, width, height);
|
||||
});
|
||||
emptyElement.classList.toggle("is-hidden", Boolean(image));
|
||||
badgeElement.textContent =
|
||||
`x ${plane.xMin.toFixed(2)}…${plane.xMax.toFixed(2)} m · ` +
|
||||
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m`;
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!current || !current.selectedSourceId || !finitePlane(current.plane)) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const xRatio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const yRatio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||||
onMove(pixelToCoordinate({ x: xRatio * rect.width, y: yRatio * rect.height }, current.plane, rect.width, rect.height));
|
||||
});
|
||||
|
||||
new ResizeObserver(() => {
|
||||
if (current) render(current.scene, current.image, current.plane, current.selectedSourceId);
|
||||
}).observe(canvas);
|
||||
|
||||
return { render };
|
||||
}
|
||||
+143
-11
@@ -6,27 +6,159 @@
|
||||
<meta name="description" content="micarrsim 음향 장면 및 ground truth 생성기" />
|
||||
<title>Dataset Builder · micarrsim</title>
|
||||
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||
<link rel="stylesheet" href="/apps/dataset-builder/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="app-shell builder-shell">
|
||||
<header class="app-header builder-header">
|
||||
<a class="app-brand" href="/">
|
||||
<strong>micarrsim</strong>
|
||||
<span>Dataset Builder</span>
|
||||
</a>
|
||||
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||
<div class="header-actions">
|
||||
<button class="button button--ghost" id="preview-button" type="button">▶ 미리 듣기</button>
|
||||
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
|
||||
<button class="button button--primary" id="export-button" type="button">Scene JSON 저장</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<section class="empty-state">
|
||||
<p class="eyebrow">Scene authoring</p>
|
||||
<h1>음향 장면 만들기</h1>
|
||||
<p>
|
||||
이 화면에서 기기 이미지에 음원을 배치하고 ambient audio를 혼합해 scene manifest와
|
||||
ground truth를 만들게 됩니다. 공통 데이터 계약을 확정한 뒤 편집기를 연결합니다.
|
||||
</p>
|
||||
|
||||
<main class="builder-layout">
|
||||
<section class="scene-workspace" aria-label="장면 편집 화면">
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Ground-truth canvas</p>
|
||||
<h1 id="workspace-title">음향 장면</h1>
|
||||
</div>
|
||||
<div class="workspace-tools">
|
||||
<label class="button button--ghost file-button">
|
||||
배경 이미지
|
||||
<input id="image-input" type="file" accept="image/*" />
|
||||
</label>
|
||||
<button class="button button--danger is-hidden" id="remove-image-button" type="button">이미지 해제</button>
|
||||
<label class="button button--ghost file-button">
|
||||
오디오 추가
|
||||
<input id="audio-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
|
||||
</label>
|
||||
<label class="button button--ghost file-button">
|
||||
Scene 불러오기
|
||||
<input id="scene-input" type="file" accept="application/json,.json" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-card">
|
||||
<canvas id="scene-canvas" aria-label="이미지 평면과 음원 ground truth"></canvas>
|
||||
<div class="canvas-empty" id="canvas-empty">
|
||||
<strong>이미지를 추가하거나 grid를 그대로 사용하세요.</strong>
|
||||
<span>선택한 음원을 옮기려면 canvas를 클릭하세요.</span>
|
||||
</div>
|
||||
<div class="canvas-badge" id="canvas-badge">x −0.60…0.60 m · y −0.45…0.45 m</div>
|
||||
</div>
|
||||
|
||||
<div class="source-strip-heading">
|
||||
<div>
|
||||
<h2>음원 ground truth</h2>
|
||||
<p>카드를 선택한 뒤 canvas를 클릭해 위치를 지정합니다.</p>
|
||||
</div>
|
||||
<button class="button button--primary" id="add-source-button" type="button">+ 음원 추가</button>
|
||||
</div>
|
||||
<div class="source-strip" id="source-list" aria-label="음원 목록"></div>
|
||||
|
||||
<section class="asset-shelf">
|
||||
<div>
|
||||
<h2>로컬 assets</h2>
|
||||
<p>원본 파일은 업로드되지 않습니다. Camera에서 JSON과 함께 같은 파일을 선택하세요.</p>
|
||||
</div>
|
||||
<div class="asset-list" id="asset-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="control-panel" aria-label="장면 파라미터">
|
||||
<div class="runtime-line">
|
||||
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||
<span class="save-state" id="save-state">기본 장면</span>
|
||||
</div>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>01</span><h2>장면</h2></div>
|
||||
<label class="field field--wide">
|
||||
<span>이름</span>
|
||||
<input id="scene-name" type="text" maxlength="120" />
|
||||
</label>
|
||||
<label class="field field--wide">
|
||||
<span>Scene ID</span>
|
||||
<input id="scene-id" type="text" maxlength="120" pattern="[a-z0-9][a-z0-9._-]*" />
|
||||
</label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>길이 (s)</span><input id="scene-duration" type="number" min="0.05" max="300" step="0.05" /></label>
|
||||
<label class="field"><span>Sample rate</span><input id="sample-rate" type="number" min="8000" max="192000" step="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>Mix 정규화</span><select id="mix-normalization"><option value="none">없음</option><option value="peak">Peak</option></select></label>
|
||||
<label class="field"><span>Target peak</span><input id="target-peak" type="number" min="0.01" max="1" step="0.01" /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>02</span><h2>표시 평면</h2></div>
|
||||
<div class="field-grid field-grid--compact">
|
||||
<label class="field"><span>x min</span><input id="plane-x-min" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>x max</span><input id="plane-x-max" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>y min</span><input id="plane-y-min" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>y max</span><input id="plane-y-max" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>plane z (m)</span><input id="plane-z" type="number" min="0.01" step="0.1" /></label>
|
||||
</div>
|
||||
<p class="section-note">x/y는 편집 화면 범위이며, 배경 이미지가 있을 때 image plane으로 JSON에 저장됩니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="control-section" id="source-editor-section">
|
||||
<div class="section-title"><span>03</span><h2>선택 음원</h2></div>
|
||||
<div id="no-source-message" class="inline-note">음원을 선택하세요.</div>
|
||||
<div id="source-fields">
|
||||
<label class="field field--wide"><span>이름</span><input id="source-name" type="text" maxlength="120" /></label>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label class="field"><span>x (m)</span><input id="source-x" type="number" step="0.01" /></label>
|
||||
<label class="field"><span>y (m)</span><input id="source-y" type="number" step="0.01" /></label>
|
||||
<label class="field"><span>거리 z (m)</span><input id="source-z" type="number" min="0.01" step="0.1" /></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label class="field"><span>Gain (dB)</span><input id="source-gain" type="number" min="-120" max="24" step="1" /></label>
|
||||
<label class="field"><span>시작 (s)</span><input id="source-start" type="number" min="0" step="0.01" /></label>
|
||||
<label class="field"><span>길이 (s)</span><input id="source-duration" type="number" min="0.01" step="0.01" /></label>
|
||||
</div>
|
||||
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
||||
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
|
||||
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
||||
<div id="asset-fields">
|
||||
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>Channel</span><input id="source-channel" type="number" min="0" max="255" step="1" /></label>
|
||||
<label class="check-field"><input id="source-loop" type="checkbox" /><span>짧은 파일 반복</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button button--danger button--full" id="remove-source-button" type="button">선택 음원 삭제</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>04</span><h2>Ambient</h2></div>
|
||||
<label class="field field--wide"><span>종류</span><select id="ambient-type"><option value="none">없음</option><option value="white">White noise</option><option value="pink">Pink noise</option><option value="asset">Audio asset</option></select></label>
|
||||
<label class="field field--wide" id="ambient-asset-field"><span>Audio asset</span><select id="ambient-asset"></select></label>
|
||||
<div class="field-grid" id="ambient-asset-options">
|
||||
<label class="field"><span>Channel</span><input id="ambient-channel" type="number" min="0" max="255" step="1" /></label>
|
||||
<label class="check-field"><input id="ambient-loop" type="checkbox" /><span>짧은 파일 반복</span></label>
|
||||
</div>
|
||||
<label class="field field--wide"><span>Gain (dB)</span><input id="ambient-gain" type="number" min="-120" max="24" step="1" /></label>
|
||||
</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>
|
||||
<script type="module" src="/apps/dataset-builder/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
||||
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
|
||||
import { downloadBlob, downloadJson, readJsonFile, sha256File } from "/packages/contracts/io.js";
|
||||
import { validateScene } from "/packages/contracts/validation.js";
|
||||
import { createAudioRuntime } from "./audio-runtime.js";
|
||||
import { createSceneCanvas } from "./canvas-view.js";
|
||||
import {
|
||||
DEFAULT_VIEW_PLANE,
|
||||
SOURCE_COLORS,
|
||||
assetDescriptorForFile,
|
||||
changeSignalType,
|
||||
createSceneId,
|
||||
createSource,
|
||||
describeSignal,
|
||||
isAssetReferenced,
|
||||
matchAssetDescriptor,
|
||||
prepareSceneForEditing,
|
||||
referencedAssetIds,
|
||||
safeDownloadStem,
|
||||
} from "./model.js";
|
||||
|
||||
const byId = (id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) throw new Error(`필수 UI를 찾을 수 없습니다: #${id}`);
|
||||
return element;
|
||||
};
|
||||
|
||||
const elements = Object.fromEntries(
|
||||
[
|
||||
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-input", "scene-input",
|
||||
"scene-canvas", "canvas-empty", "canvas-badge", "workspace-title", "source-list", "add-source-button",
|
||||
"asset-list", "scene-name", "scene-id", "scene-duration", "sample-rate", "speed-of-sound", "scene-seed",
|
||||
"mix-normalization", "target-peak", "plane-x-min", "plane-x-max", "plane-y-min", "plane-y-max",
|
||||
"plane-z", "no-source-message", "source-fields", "source-name", "source-x", "source-y", "source-z",
|
||||
"source-gain", "source-start", "source-duration", "source-type", "tone-fields", "tone-frequency",
|
||||
"noise-fields", "noise-color", "asset-fields", "source-asset", "source-channel", "source-loop",
|
||||
"remove-source-button", "ambient-type", "ambient-asset-field", "ambient-asset-options", "ambient-asset",
|
||||
"ambient-channel", "ambient-loop", "ambient-gain",
|
||||
"validation-dot", "validation-title", "validation-message", "save-state",
|
||||
].map((id) => [id, byId(id)]),
|
||||
);
|
||||
elements.validationCard = document.querySelector(".validation-card");
|
||||
|
||||
const state = {
|
||||
scene: null,
|
||||
selectedSourceId: null,
|
||||
viewPlane: { ...DEFAULT_VIEW_PLANE },
|
||||
imageElement: null,
|
||||
imageUrl: null,
|
||||
assetFiles: new Map(),
|
||||
decodedAudio: new Map(),
|
||||
assetErrors: new Map(),
|
||||
assetEpoch: 0,
|
||||
imageJobRevision: 0,
|
||||
dirty: false,
|
||||
validationRevision: 0,
|
||||
mixActionActive: false,
|
||||
mixActionCancelled: false,
|
||||
mixAbortController: null,
|
||||
};
|
||||
|
||||
const MAX_LOCAL_ASSET_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_BOUND_ASSET_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
const audioRuntime = createAudioRuntime();
|
||||
const canvasView = createSceneCanvas(
|
||||
elements["scene-canvas"],
|
||||
elements["canvas-empty"],
|
||||
elements["canvas-badge"],
|
||||
({ x, y }) => {
|
||||
const source = selectedSource();
|
||||
if (!source) return;
|
||||
source.position[0] = Number(x.toFixed(4));
|
||||
source.position[1] = Number(y.toFixed(4));
|
||||
syncSourceEditor();
|
||||
markChanged("위치 변경됨");
|
||||
renderSourceCards();
|
||||
renderCanvas();
|
||||
},
|
||||
);
|
||||
|
||||
function selectedSource() {
|
||||
return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null;
|
||||
}
|
||||
|
||||
function numberFrom(element) {
|
||||
return element.valueAsNumber;
|
||||
}
|
||||
|
||||
function setNumber(element, value) {
|
||||
element.value = Number.isFinite(value) ? String(value) : "";
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
elements["save-state"].textContent = message;
|
||||
}
|
||||
|
||||
function cancelPendingMix() {
|
||||
if (state.mixActionActive) {
|
||||
state.mixActionCancelled = true;
|
||||
state.mixAbortController?.abort();
|
||||
audioRuntime.cancelMix();
|
||||
}
|
||||
}
|
||||
|
||||
function markChanged(message = "저장하지 않은 변경") {
|
||||
cancelPendingMix();
|
||||
if (audioRuntime.stopPlayback()) elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
state.dirty = true;
|
||||
setStatus(message);
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
function currentPlaneZ() {
|
||||
return state.scene.image?.plane?.z ?? selectedSource()?.position?.[2] ?? 2;
|
||||
}
|
||||
|
||||
function syncImagePlane() {
|
||||
if (!state.scene.image) return;
|
||||
Object.assign(state.scene.image.plane, state.viewPlane, { z: numberFrom(elements["plane-z"]) });
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
if (!state.scene) return;
|
||||
canvasView.render(state.scene, state.imageElement, state.viewPlane, state.selectedSourceId);
|
||||
}
|
||||
|
||||
function renderSourceCards() {
|
||||
const list = elements["source-list"];
|
||||
list.replaceChildren();
|
||||
state.scene.sources.forEach((source, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "source-card";
|
||||
button.classList.toggle("is-selected", source.id === state.selectedSourceId);
|
||||
button.style.setProperty("--source-color", SOURCE_COLORS[index % SOURCE_COLORS.length]);
|
||||
const heading = document.createElement("span");
|
||||
heading.className = "source-card__heading";
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "source-card__dot";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = source.name;
|
||||
heading.append(dot, name);
|
||||
const signal = document.createElement("small");
|
||||
signal.textContent = describeSignal(source.signal);
|
||||
const position = document.createElement("small");
|
||||
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m`;
|
||||
button.append(heading, signal, position);
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedSourceId = source.id;
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
renderCanvas();
|
||||
});
|
||||
list.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function fillAudioSelect(select, selectedId, placeholder) {
|
||||
select.replaceChildren();
|
||||
const audioAssets = state.scene.assets.filter((asset) => asset.kind === "audio");
|
||||
const empty = document.createElement("option");
|
||||
empty.value = "";
|
||||
empty.textContent = audioAssets.length > 0 ? placeholder : "먼저 오디오를 추가하세요";
|
||||
select.append(empty);
|
||||
for (const asset of audioAssets) {
|
||||
const option = document.createElement("option");
|
||||
option.value = asset.id;
|
||||
option.textContent = asset.fileName;
|
||||
select.append(option);
|
||||
}
|
||||
if (selectedId && !audioAssets.some((asset) => asset.id === selectedId)) {
|
||||
const missing = document.createElement("option");
|
||||
missing.value = selectedId;
|
||||
missing.textContent = `${selectedId} (manifest 누락)`;
|
||||
select.append(missing);
|
||||
}
|
||||
select.value = selectedId ?? "";
|
||||
}
|
||||
|
||||
function syncSourceEditor() {
|
||||
const source = selectedSource();
|
||||
elements["no-source-message"].classList.toggle("is-hidden", Boolean(source));
|
||||
elements["source-fields"].classList.toggle("is-hidden", !source);
|
||||
if (!source) return;
|
||||
elements["source-name"].value = source.name;
|
||||
setNumber(elements["source-x"], source.position[0]);
|
||||
setNumber(elements["source-y"], source.position[1]);
|
||||
setNumber(elements["source-z"], source.position[2]);
|
||||
setNumber(elements["source-gain"], source.gainDb);
|
||||
setNumber(elements["source-start"], source.startTime);
|
||||
setNumber(elements["source-duration"], source.duration);
|
||||
elements["source-type"].value = source.signal.type;
|
||||
elements["tone-fields"].classList.toggle("is-hidden", source.signal.type !== "tone");
|
||||
elements["noise-fields"].classList.toggle("is-hidden", source.signal.type !== "noise");
|
||||
elements["asset-fields"].classList.toggle("is-hidden", source.signal.type !== "asset");
|
||||
if (source.signal.type === "tone") setNumber(elements["tone-frequency"], source.signal.frequency);
|
||||
if (source.signal.type === "noise") elements["noise-color"].value = source.signal.color;
|
||||
if (source.signal.type === "asset") {
|
||||
fillAudioSelect(elements["source-asset"], source.signal.assetId, "Audio asset 선택");
|
||||
setNumber(elements["source-channel"], source.signal.channel ?? 0);
|
||||
elements["source-loop"].checked = source.signal.loop ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
function ambientEntry() {
|
||||
return state.scene.ambient[0] ?? null;
|
||||
}
|
||||
|
||||
function syncAmbientEditor() {
|
||||
const ambient = ambientEntry();
|
||||
const type = ambient?.signal?.type === "noise" ? ambient.signal.color : ambient?.signal?.type ?? "none";
|
||||
elements["ambient-type"].value = type;
|
||||
elements["ambient-asset-field"].classList.toggle("is-hidden", type !== "asset");
|
||||
elements["ambient-asset-options"].classList.toggle("is-hidden", type !== "asset");
|
||||
fillAudioSelect(elements["ambient-asset"], type === "asset" ? ambient.signal.assetId : "", "Audio asset 선택");
|
||||
setNumber(elements["ambient-channel"], type === "asset" ? ambient.signal.channel ?? 0 : 0);
|
||||
elements["ambient-loop"].checked = type === "asset" ? ambient.signal.loop ?? false : false;
|
||||
setNumber(elements["ambient-gain"], ambient?.gainDb ?? -36);
|
||||
}
|
||||
|
||||
function renderAssetList() {
|
||||
const list = elements["asset-list"];
|
||||
list.replaceChildren();
|
||||
if (state.scene.assets.length === 0) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "inline-note";
|
||||
empty.textContent = "아직 asset이 없습니다.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const asset of state.scene.assets) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "asset-chip";
|
||||
const label = document.createElement("span");
|
||||
label.className = "asset-chip__label";
|
||||
label.textContent = `${asset.kind} · ${asset.fileName}`;
|
||||
label.title = `${asset.id}${asset.sha256 ? ` · ${asset.sha256}` : ""}`;
|
||||
const availability = document.createElement("span");
|
||||
availability.className = "asset-chip__state";
|
||||
availability.textContent = state.assetErrors.has(asset.id)
|
||||
? "오류"
|
||||
: state.assetFiles.has(asset.id) ? "연결됨" : "파일 필요";
|
||||
if (state.assetErrors.has(asset.id)) availability.title = state.assetErrors.get(asset.id);
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "asset-chip__remove";
|
||||
remove.textContent = "×";
|
||||
const referenced = isAssetReferenced(state.scene, asset.id);
|
||||
remove.disabled = referenced;
|
||||
remove.title = referenced ? "먼저 이 asset을 사용하는 image/source/ambient를 변경하세요." : `${asset.fileName} 제거`;
|
||||
remove.addEventListener("click", () => removeAsset(asset.id));
|
||||
chip.append(label, availability, remove);
|
||||
list.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSceneControls() {
|
||||
const scene = state.scene;
|
||||
elements["scene-name"].value = scene.name;
|
||||
elements["scene-id"].value = scene.id;
|
||||
setNumber(elements["scene-duration"], scene.duration);
|
||||
elements["sample-rate"].value = String(scene.sampleRate);
|
||||
setNumber(elements["speed-of-sound"], scene.speedOfSound);
|
||||
setNumber(elements["scene-seed"], scene.seed);
|
||||
elements["mix-normalization"].value = scene.mix.normalization;
|
||||
setNumber(elements["target-peak"], scene.mix.targetPeak);
|
||||
setNumber(elements["plane-x-min"], state.viewPlane.xMin);
|
||||
setNumber(elements["plane-x-max"], state.viewPlane.xMax);
|
||||
setNumber(elements["plane-y-min"], state.viewPlane.yMin);
|
||||
setNumber(elements["plane-y-max"], state.viewPlane.yMax);
|
||||
setNumber(elements["plane-z"], currentPlaneZ());
|
||||
elements["plane-z"].disabled = !scene.image;
|
||||
elements["remove-image-button"].classList.toggle("is-hidden", !scene.image);
|
||||
elements["target-peak"].disabled = scene.mix.normalization !== "peak";
|
||||
elements["workspace-title"].textContent = scene.name;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
syncSceneControls();
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
syncAmbientEditor();
|
||||
renderAssetList();
|
||||
renderCanvas();
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
function requiredAssetIds({ audioOnly = false } = {}) {
|
||||
const referenced = referencedAssetIds(state.scene);
|
||||
if (!audioOnly) return [...referenced];
|
||||
const assetsById = new Map(state.scene.assets.map((asset) => [asset.id, asset]));
|
||||
return [...referenced].filter((id) => assetsById.get(id)?.kind === "audio");
|
||||
}
|
||||
|
||||
function unavailableAssetIds(options) {
|
||||
return requiredAssetIds(options).filter(
|
||||
(id) => !state.assetFiles.has(id) || state.assetErrors.has(id),
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleValidation() {
|
||||
const revision = ++state.validationRevision;
|
||||
window.clearTimeout(state.validationTimer);
|
||||
document.documentElement.dataset.sceneValid = "pending";
|
||||
document.documentElement.dataset.assetsReady = "pending";
|
||||
if (!state.mixActionActive) {
|
||||
elements["preview-button"].disabled = true;
|
||||
elements["wav-button"].disabled = true;
|
||||
elements["export-button"].disabled = true;
|
||||
}
|
||||
state.validationTimer = window.setTimeout(async () => {
|
||||
const issues = await validateScene(state.scene);
|
||||
if (revision !== state.validationRevision) return;
|
||||
const missingAll = unavailableAssetIds();
|
||||
const missingAudio = unavailableAssetIds({ audioOnly: true });
|
||||
const valid = issues.length === 0;
|
||||
document.documentElement.dataset.sceneValid = String(valid);
|
||||
document.documentElement.dataset.assetsReady = String(missingAll.length === 0);
|
||||
elements.validationCard.classList.toggle("is-valid", valid && missingAll.length === 0);
|
||||
elements.validationCard.classList.toggle("is-invalid", !valid || missingAll.length > 0);
|
||||
if (!valid) {
|
||||
elements["validation-title"].textContent = `${issues.length}개 항목을 확인하세요`;
|
||||
elements["validation-message"].textContent = issues
|
||||
.slice(0, 3)
|
||||
.map(({ path, message }) => `${path} ${message}`)
|
||||
.join(" · ");
|
||||
} else if (missingAll.length > 0) {
|
||||
elements["validation-title"].textContent = `원본 asset ${missingAll.length}개 필요`;
|
||||
elements["validation-message"].textContent = missingAll.join(", ");
|
||||
} else {
|
||||
elements["validation-title"].textContent = "저장 가능한 장면";
|
||||
elements["validation-message"].textContent = `${state.scene.sources.length}개 음원 · ${state.scene.sampleRate.toLocaleString()} Hz · ${state.scene.duration} s`;
|
||||
}
|
||||
if (!state.mixActionActive) {
|
||||
elements["preview-button"].disabled = !valid || missingAudio.length > 0;
|
||||
elements["wav-button"].disabled = !valid || missingAudio.length > 0;
|
||||
elements["export-button"].disabled = !valid || missingAll.length > 0;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function removeAsset(assetId) {
|
||||
const asset = state.scene.assets.find((candidate) => candidate.id === assetId);
|
||||
if (!asset) return;
|
||||
if (isAssetReferenced(state.scene, assetId)) {
|
||||
setStatus(`${asset.fileName}은 장면에서 사용 중입니다.`);
|
||||
return;
|
||||
}
|
||||
state.assetEpoch += 1;
|
||||
state.scene.assets = state.scene.assets.filter((candidate) => candidate.id !== assetId);
|
||||
state.assetFiles.delete(assetId);
|
||||
state.decodedAudio.delete(assetId);
|
||||
state.assetErrors.delete(assetId);
|
||||
renderAssetList();
|
||||
syncSourceEditor();
|
||||
syncAmbientEditor();
|
||||
markChanged("asset 제거됨");
|
||||
}
|
||||
|
||||
function findDescriptorForFile(file, kind, hash) {
|
||||
return matchAssetDescriptor(state.scene.assets, new Set(state.assetFiles.keys()), file.name, kind, hash);
|
||||
}
|
||||
|
||||
async function decodeImageFile(file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
if (image.naturalWidth > 32768 || image.naturalHeight > 32768) {
|
||||
throw new Error("이미지는 가로·세로 32,768 px 이하여야 합니다.");
|
||||
}
|
||||
return { image, url };
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFile(file, kind, expectedEpoch = state.assetEpoch) {
|
||||
const epoch = expectedEpoch;
|
||||
if (file.size > MAX_LOCAL_ASSET_BYTES) {
|
||||
throw new Error(`${file.name}은 ${(MAX_LOCAL_ASSET_BYTES / 1024 ** 2).toFixed(0)} MiB 이하여야 합니다.`);
|
||||
}
|
||||
setStatus(`${file.name} SHA-256 계산 중…`);
|
||||
const hash = await sha256File(file);
|
||||
if (epoch !== state.assetEpoch) return null;
|
||||
const match = findDescriptorForFile(file, kind, hash);
|
||||
let descriptor = match.descriptor;
|
||||
if (!descriptor) {
|
||||
descriptor = assetDescriptorForFile(file, kind, new Set(state.scene.assets.map((asset) => asset.id)));
|
||||
state.scene.assets.push(descriptor);
|
||||
}
|
||||
const currentFileBytes = state.assetFiles.get(descriptor.id)?.size ?? 0;
|
||||
const boundBytes = [...state.assetFiles.values()].reduce((sum, candidate) => sum + candidate.size, 0);
|
||||
if (boundBytes - currentFileBytes + file.size > MAX_BOUND_ASSET_BYTES) {
|
||||
if (!match.descriptor) state.scene.assets = state.scene.assets.filter((asset) => asset !== descriptor);
|
||||
throw new Error(`연결한 asset 파일 합계는 ${(MAX_BOUND_ASSET_BYTES / 1024 ** 2).toFixed(0)} MiB 이하여야 합니다.`);
|
||||
}
|
||||
descriptor.sha256 = hash;
|
||||
if (file.type) descriptor.mimeType = file.type;
|
||||
state.assetFiles.set(descriptor.id, file);
|
||||
state.decodedAudio.delete(descriptor.id);
|
||||
state.assetErrors.delete(descriptor.id);
|
||||
if (match.conflict) {
|
||||
setStatus(`같은 이름의 다른 파일을 ${descriptor.id}로 분리했습니다.`);
|
||||
} else {
|
||||
setStatus(`${file.name} 연결됨 · SHA ${hash.slice(0, 10)}…`);
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function decodedAudioBytes(decoded) {
|
||||
return decoded.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
|
||||
}
|
||||
|
||||
function abortIfMixChanged(expectedEpoch) {
|
||||
if (state.mixActionCancelled || expectedEpoch !== state.assetEpoch) {
|
||||
throw new DOMException("장면 변경으로 mix 생성을 취소했습니다.", "AbortError");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDecodedAudio(expectedEpoch, signal) {
|
||||
const buffers = new Map();
|
||||
const requiredIds = new Set(requiredAssetIds({ audioOnly: true }));
|
||||
for (const assetId of state.decodedAudio.keys()) {
|
||||
if (!requiredIds.has(assetId)) state.decodedAudio.delete(assetId);
|
||||
}
|
||||
let retainedBytes = 0;
|
||||
for (const [assetId, decoded] of state.decodedAudio) {
|
||||
if (requiredIds.has(assetId) && decoded.sampleRate === state.scene.sampleRate) retainedBytes += decodedAudioBytes(decoded);
|
||||
}
|
||||
for (const assetId of requiredIds) {
|
||||
abortIfMixChanged(expectedEpoch);
|
||||
const file = state.assetFiles.get(assetId);
|
||||
const asset = state.scene.assets.find((candidate) => candidate.id === assetId);
|
||||
if (!file || !asset) throw new Error(`오디오 원본 파일이 필요합니다: ${assetId}`);
|
||||
let decoded = state.decodedAudio.get(assetId);
|
||||
if (!decoded || decoded.sampleRate !== state.scene.sampleRate) {
|
||||
setStatus(`${asset.fileName} ${state.scene.sampleRate.toLocaleString()} Hz decode 중…`);
|
||||
try {
|
||||
const remainingBytes = MAX_DECODED_AUDIO_BYTES - retainedBytes;
|
||||
if (remainingBytes <= 0) throw new Error("참조 오디오의 누적 decode 메모리 한도를 넘습니다.");
|
||||
decoded = await decodeAudioFile(file, state.scene.sampleRate, remainingBytes, signal);
|
||||
abortIfMixChanged(expectedEpoch);
|
||||
state.decodedAudio.set(assetId, decoded);
|
||||
retainedBytes += decodedAudioBytes(decoded);
|
||||
state.assetErrors.delete(assetId);
|
||||
} catch (error) {
|
||||
if (expectedEpoch === state.assetEpoch && !state.mixActionCancelled) {
|
||||
state.assetErrors.set(assetId, error instanceof Error ? error.message : String(error));
|
||||
renderAssetList();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
buffers.set(assetId, decoded);
|
||||
}
|
||||
return buffers;
|
||||
}
|
||||
|
||||
function formatMixStats(result) {
|
||||
const values = [
|
||||
`입력 peak ${result.preNormalizationPeak.toFixed(4)}`,
|
||||
`scale ${result.appliedScale.toFixed(4)}`,
|
||||
`출력 peak ${result.peak.toFixed(4)}`,
|
||||
];
|
||||
if (result.clippedSampleCount > 0) values.push(`clipping ${result.clippedSampleCount.toLocaleString()} samples`);
|
||||
return values.join(" · ");
|
||||
}
|
||||
|
||||
async function runMixAction(button, busyLabel, onResult) {
|
||||
if (state.mixActionActive) {
|
||||
cancelPendingMix();
|
||||
setStatus("확인용 mix 생성을 취소했습니다.");
|
||||
return;
|
||||
}
|
||||
state.mixActionActive = true;
|
||||
state.mixActionCancelled = false;
|
||||
state.mixAbortController = new AbortController();
|
||||
const buttons = [elements["preview-button"], elements["wav-button"], elements["export-button"]];
|
||||
const expectedEpoch = state.assetEpoch;
|
||||
const originalText = button.textContent;
|
||||
buttons.forEach((candidate) => { candidate.disabled = candidate !== button; });
|
||||
button.textContent = "취소";
|
||||
try {
|
||||
setStatus(busyLabel);
|
||||
const assetBuffers = await ensureDecodedAudio(expectedEpoch, state.mixAbortController.signal);
|
||||
abortIfMixChanged(expectedEpoch);
|
||||
const result = await audioRuntime.renderMix(structuredClone(state.scene), assetBuffers);
|
||||
await onResult(result);
|
||||
setStatus(formatMixStats(result));
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
state.mixActionActive = false;
|
||||
state.mixAbortController = null;
|
||||
button.textContent = originalText;
|
||||
scheduleValidation();
|
||||
}
|
||||
}
|
||||
|
||||
function bindInput(id, callback, eventName = "input") {
|
||||
elements[id].addEventListener(eventName, () => {
|
||||
callback(elements[id]);
|
||||
markChanged();
|
||||
});
|
||||
}
|
||||
|
||||
function bindSceneControls() {
|
||||
bindInput("scene-name", (input) => {
|
||||
state.scene.name = input.value;
|
||||
elements["workspace-title"].textContent = input.value || "이름 없는 장면";
|
||||
});
|
||||
bindInput("scene-id", (input) => { state.scene.id = input.value; });
|
||||
bindInput("scene-duration", (input) => { state.scene.duration = numberFrom(input); });
|
||||
bindInput("sample-rate", (input) => {
|
||||
state.scene.sampleRate = Number(input.value);
|
||||
state.decodedAudio.clear();
|
||||
for (const asset of state.scene.assets) {
|
||||
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
||||
}
|
||||
renderAssetList();
|
||||
}, "change");
|
||||
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
||||
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
||||
bindInput("mix-normalization", (input) => {
|
||||
state.scene.mix.normalization = input.value;
|
||||
elements["target-peak"].disabled = input.value !== "peak";
|
||||
}, "change");
|
||||
bindInput("target-peak", (input) => { state.scene.mix.targetPeak = numberFrom(input); });
|
||||
|
||||
for (const [id, key] of [
|
||||
["plane-x-min", "xMin"], ["plane-x-max", "xMax"],
|
||||
["plane-y-min", "yMin"], ["plane-y-max", "yMax"],
|
||||
]) {
|
||||
bindInput(id, (input) => {
|
||||
state.viewPlane[key] = numberFrom(input);
|
||||
syncImagePlane();
|
||||
renderCanvas();
|
||||
});
|
||||
}
|
||||
bindInput("plane-z", () => { syncImagePlane(); });
|
||||
}
|
||||
|
||||
function bindSourceControls() {
|
||||
elements["add-source-button"].addEventListener("click", () => {
|
||||
if (state.scene.sources.length >= 32) {
|
||||
setStatus("음원은 최대 32개까지 추가할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
const source = createSource(state.scene);
|
||||
state.scene.sources.push(source);
|
||||
state.selectedSourceId = source.id;
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
renderCanvas();
|
||||
markChanged("음원 추가됨");
|
||||
});
|
||||
elements["remove-source-button"].addEventListener("click", () => {
|
||||
if (state.scene.sources.length <= 1) {
|
||||
setStatus("장면에는 음원이 하나 이상 필요합니다.");
|
||||
return;
|
||||
}
|
||||
state.scene.sources = state.scene.sources.filter((source) => source.id !== state.selectedSourceId);
|
||||
state.selectedSourceId = state.scene.sources[0].id;
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
renderCanvas();
|
||||
markChanged("음원 삭제됨");
|
||||
});
|
||||
|
||||
bindInput("source-name", (input) => { selectedSource().name = input.value; renderSourceCards(); });
|
||||
for (const [id, index] of [["source-x", 0], ["source-y", 1], ["source-z", 2]]) {
|
||||
bindInput(id, (input) => {
|
||||
selectedSource().position[index] = numberFrom(input);
|
||||
renderSourceCards();
|
||||
renderCanvas();
|
||||
});
|
||||
}
|
||||
bindInput("source-gain", (input) => { selectedSource().gainDb = numberFrom(input); });
|
||||
bindInput("source-start", (input) => { selectedSource().startTime = numberFrom(input); });
|
||||
bindInput("source-duration", (input) => { selectedSource().duration = numberFrom(input); });
|
||||
bindInput("source-type", (input) => {
|
||||
const firstAudioId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
|
||||
changeSignalType(selectedSource(), input.value, firstAudioId);
|
||||
syncSourceEditor();
|
||||
renderSourceCards();
|
||||
}, "change");
|
||||
bindInput("tone-frequency", (input) => { selectedSource().signal.frequency = numberFrom(input); renderSourceCards(); });
|
||||
bindInput("noise-color", (input) => { selectedSource().signal.color = input.value; renderSourceCards(); }, "change");
|
||||
bindInput("source-asset", (input) => { selectedSource().signal.assetId = input.value; renderSourceCards(); }, "change");
|
||||
bindInput("source-channel", (input) => { selectedSource().signal.channel = numberFrom(input); });
|
||||
bindInput("source-loop", (input) => { selectedSource().signal.loop = input.checked; }, "change");
|
||||
}
|
||||
|
||||
function createAmbientSignal(type) {
|
||||
if (type === "white" || type === "pink") return { type: "noise", color: type, seedOffset: 0 };
|
||||
const assetId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
|
||||
return { type: "asset", assetId, channel: 0, loop: true };
|
||||
}
|
||||
|
||||
function bindAmbientControls() {
|
||||
bindInput("ambient-type", (input) => {
|
||||
if (input.value === "none") {
|
||||
state.scene.ambient = [];
|
||||
} else {
|
||||
const current = ambientEntry();
|
||||
const rest = state.scene.ambient.slice(1);
|
||||
const first = {
|
||||
id: current?.id ?? "ambient-1",
|
||||
gainDb: current?.gainDb ?? -36,
|
||||
signal: createAmbientSignal(input.value),
|
||||
};
|
||||
state.scene.ambient = [first, ...rest];
|
||||
}
|
||||
syncAmbientEditor();
|
||||
}, "change");
|
||||
bindInput("ambient-gain", (input) => {
|
||||
if (ambientEntry()) ambientEntry().gainDb = numberFrom(input);
|
||||
});
|
||||
bindInput("ambient-asset", (input) => {
|
||||
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.assetId = input.value;
|
||||
}, "change");
|
||||
bindInput("ambient-channel", (input) => {
|
||||
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.channel = numberFrom(input);
|
||||
});
|
||||
bindInput("ambient-loop", (input) => {
|
||||
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.loop = input.checked;
|
||||
}, "change");
|
||||
}
|
||||
|
||||
async function handleImageFile(file, imageJobRevision, epoch) {
|
||||
const descriptor = await attachFile(file, "image", epoch);
|
||||
if (!descriptor || imageJobRevision !== state.imageJobRevision) return;
|
||||
try {
|
||||
const loaded = await decodeImageFile(file);
|
||||
if (epoch !== state.assetEpoch || imageJobRevision !== state.imageJobRevision) {
|
||||
URL.revokeObjectURL(loaded.url);
|
||||
return;
|
||||
}
|
||||
const { image } = loaded;
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
state.imageUrl = loaded.url;
|
||||
state.imageElement = image;
|
||||
const existingPlane = state.scene.image?.assetId === descriptor.id ? state.scene.image.plane : null;
|
||||
const plane = existingPlane
|
||||
? { ...existingPlane }
|
||||
: { ...state.viewPlane, z: currentPlaneZ() };
|
||||
state.scene.image = {
|
||||
assetId: descriptor.id,
|
||||
widthPixels: image.naturalWidth,
|
||||
heightPixels: image.naturalHeight,
|
||||
plane,
|
||||
};
|
||||
state.viewPlane = {
|
||||
xMin: plane.xMin, xMax: plane.xMax, yMin: plane.yMin, yMax: plane.yMax,
|
||||
};
|
||||
state.assetErrors.delete(descriptor.id);
|
||||
renderAll();
|
||||
markChanged("배경 이미지 연결됨");
|
||||
} catch (error) {
|
||||
if (epoch === state.assetEpoch && imageJobRevision === state.imageJobRevision) {
|
||||
state.assetErrors.set(descriptor.id, error instanceof Error ? error.message : String(error));
|
||||
renderAssetList();
|
||||
scheduleValidation();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function bindFileControls() {
|
||||
elements["image-input"].addEventListener("change", async (event) => {
|
||||
const [file] = event.target.files;
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
cancelPendingMix();
|
||||
const imageJobRevision = ++state.imageJobRevision;
|
||||
const epoch = ++state.assetEpoch;
|
||||
try {
|
||||
await handleImageFile(file, imageJobRevision, epoch);
|
||||
} catch (error) {
|
||||
if (imageJobRevision === state.imageJobRevision) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
elements["remove-image-button"].addEventListener("click", () => {
|
||||
if (!state.scene.image) return;
|
||||
cancelPendingMix();
|
||||
state.imageJobRevision += 1;
|
||||
state.assetEpoch += 1;
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
state.imageUrl = null;
|
||||
state.imageElement = null;
|
||||
delete state.scene.image;
|
||||
renderAll();
|
||||
markChanged("배경 이미지 해제됨");
|
||||
});
|
||||
elements["audio-input"].addEventListener("change", async (event) => {
|
||||
const files = [...event.target.files];
|
||||
event.target.value = "";
|
||||
const epoch = state.assetEpoch;
|
||||
for (const file of files) {
|
||||
if (epoch !== state.assetEpoch) return;
|
||||
try {
|
||||
await attachFile(file, "audio", epoch);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
if (epoch !== state.assetEpoch) return;
|
||||
if (files.length > 0) {
|
||||
renderAssetList();
|
||||
syncSourceEditor();
|
||||
syncAmbientEditor();
|
||||
markChanged(`${files.length}개 오디오 처리 완료`);
|
||||
}
|
||||
});
|
||||
elements["scene-input"].addEventListener("change", async (event) => {
|
||||
const [file] = event.target.files;
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
cancelPendingMix();
|
||||
if (audioRuntime.stopPlayback()) elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
try {
|
||||
const candidate = await readJsonFile(file);
|
||||
const issues = await validateScene(candidate);
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues.slice(0, 4).map(({ path, message }) => `${path} ${message}`).join(" · "));
|
||||
}
|
||||
state.assetEpoch += 1;
|
||||
state.imageJobRevision += 1;
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
state.imageUrl = null;
|
||||
state.imageElement = null;
|
||||
state.assetFiles.clear();
|
||||
state.decodedAudio.clear();
|
||||
state.assetErrors.clear();
|
||||
state.scene = prepareSceneForEditing(candidate);
|
||||
state.selectedSourceId = state.scene.sources[0].id;
|
||||
state.viewPlane = state.scene.image
|
||||
? {
|
||||
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
|
||||
yMin: state.scene.image.plane.yMin, yMax: state.scene.image.plane.yMax,
|
||||
}
|
||||
: { ...DEFAULT_VIEW_PLANE };
|
||||
state.dirty = false;
|
||||
renderAll();
|
||||
setStatus(`Scene 불러옴 · ${requiredAssetIds().length}개 원본 asset을 다시 선택하세요.`);
|
||||
} catch (error) {
|
||||
setStatus(`불러오기 실패 · ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindOutputControls() {
|
||||
elements["preview-button"].addEventListener("click", async () => {
|
||||
if (audioRuntime.stopPlayback()) {
|
||||
elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
setStatus("미리 듣기를 중지했습니다.");
|
||||
return;
|
||||
}
|
||||
await runMixAction(elements["preview-button"], "확인용 mono mix 생성 중…", async (result) => {
|
||||
const stats = formatMixStats(result);
|
||||
await audioRuntime.play(result, () => {
|
||||
elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
setStatus(`미리 듣기 완료 · ${stats}`);
|
||||
});
|
||||
});
|
||||
if (audioRuntime.isPlaying) elements["preview-button"].textContent = "■ 중지";
|
||||
});
|
||||
elements["wav-button"].addEventListener("click", async () => {
|
||||
await runMixAction(elements["wav-button"], "확인용 WAV 생성 중…", async (result) => {
|
||||
if (result.clippedSampleCount > 0) {
|
||||
const shouldSave = window.confirm(
|
||||
`${result.clippedSampleCount.toLocaleString()}개 sample이 PCM16 범위에서 잘립니다. 그래도 저장할까요?`,
|
||||
);
|
||||
if (!shouldSave) return;
|
||||
}
|
||||
const blob = encodeMonoPcm16Wav(result.samples, result.sampleRate);
|
||||
downloadBlob(blob, `${safeDownloadStem(state.scene)}.preview-mix.wav`);
|
||||
});
|
||||
});
|
||||
elements["export-button"].addEventListener("click", async () => {
|
||||
try {
|
||||
const issues = await validateScene(state.scene);
|
||||
if (issues.length > 0) throw new Error(`${issues[0].path} ${issues[0].message}`);
|
||||
const unavailable = unavailableAssetIds();
|
||||
if (unavailable.length > 0) throw new Error(`원본 asset을 연결하세요: ${unavailable.join(", ")}`);
|
||||
downloadJson(state.scene, `${safeDownloadStem(state.scene)}.scene.json`);
|
||||
state.dirty = false;
|
||||
setStatus("Scene JSON 저장됨");
|
||||
} catch (error) {
|
||||
setStatus(`저장 실패 · ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
elements["preview-button"].disabled = true;
|
||||
elements["wav-button"].disabled = true;
|
||||
elements["export-button"].disabled = true;
|
||||
const response = await fetch("/assets/sample-scenes/single-tone.scene.json", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`기본 장면을 불러오지 못했습니다: HTTP ${response.status}`);
|
||||
state.scene = prepareSceneForEditing(await response.json());
|
||||
state.scene.id = createSceneId();
|
||||
state.selectedSourceId = state.scene.sources[0].id;
|
||||
if (state.scene.image) {
|
||||
state.viewPlane = {
|
||||
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
|
||||
yMin: state.scene.image.plane.yMin, yMax: state.scene.image.plane.yMax,
|
||||
};
|
||||
}
|
||||
bindSceneControls();
|
||||
bindSourceControls();
|
||||
bindAmbientControls();
|
||||
bindFileControls();
|
||||
bindOutputControls();
|
||||
renderAll();
|
||||
setStatus("기본 장면 준비됨");
|
||||
document.documentElement.dataset.appReady = "true";
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!state.dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
window.addEventListener("unload", () => {
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
audioRuntime.dispose();
|
||||
});
|
||||
|
||||
initialize().catch((error) => {
|
||||
document.documentElement.dataset.appReady = "false";
|
||||
elements["validation-title"].textContent = "초기화 실패";
|
||||
elements["validation-message"].textContent = error instanceof Error ? error.message : String(error);
|
||||
elements.validationCard.classList.add("is-invalid");
|
||||
setStatus("초기화 실패");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||
|
||||
self.addEventListener("message", ({ data }) => {
|
||||
try {
|
||||
const assetBuffers = new Map(data.assetBuffers);
|
||||
const result = renderSceneMix(data.scene, assetBuffers);
|
||||
self.postMessage({ ok: true, result }, [result.samples.buffer]);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
||||
xMin: -0.6,
|
||||
xMax: 0.6,
|
||||
yMin: -0.45,
|
||||
yMax: 0.45,
|
||||
});
|
||||
|
||||
export const SOURCE_COLORS = ["#65f2bf", "#58c7e8", "#ffc56e", "#ff8f87", "#be9bff", "#8ee36b"];
|
||||
|
||||
export function prepareSceneForEditing(scene) {
|
||||
const prepared = structuredClone(scene);
|
||||
prepared.assets ??= [];
|
||||
prepared.ambient ??= [];
|
||||
prepared.mix ??= { normalization: "none", targetPeak: 0.95 };
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function createSceneId(now = Date.now(), randomValue = crypto.getRandomValues(new Uint32Array(1))[0]) {
|
||||
return `scene-${now.toString(36)}-${randomValue.toString(36)}`;
|
||||
}
|
||||
|
||||
export function uniqueId(base, existingIds) {
|
||||
const normalized = String(base)
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "asset";
|
||||
let candidate = normalized;
|
||||
let suffix = 2;
|
||||
while (existingIds.has(candidate)) {
|
||||
candidate = `${normalized}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function assetDescriptorForFile(file, kind, existingIds) {
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "") || kind;
|
||||
return {
|
||||
id: uniqueId(`${kind}-${baseName}`, existingIds),
|
||||
kind,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || (kind === "image" ? "image/*" : "audio/*"),
|
||||
};
|
||||
}
|
||||
|
||||
export function matchAssetDescriptor(assets, boundAssetIds, fileName, kind, hash) {
|
||||
const candidates = assets.filter((asset) => asset.kind === kind);
|
||||
const hashMatches = candidates.filter((asset) => asset.sha256 === hash);
|
||||
const hashMatch = hashMatches.find((asset) => !boundAssetIds.has(asset.id)) ?? hashMatches[0];
|
||||
if (hashMatch) return { descriptor: hashMatch, conflict: false };
|
||||
const baseName = (path) => path.split(/[\\/]/).pop();
|
||||
const nameCandidates = candidates.filter((asset) => baseName(asset.fileName) === fileName);
|
||||
const compatible = nameCandidates.find((asset) => !asset.sha256 && !boundAssetIds.has(asset.id));
|
||||
if (compatible) return { descriptor: compatible, conflict: false };
|
||||
return { descriptor: null, conflict: nameCandidates.some((asset) => asset.sha256 !== hash) };
|
||||
}
|
||||
|
||||
export function createSource(scene) {
|
||||
const existingIds = new Set(scene.sources.map((source) => source.id));
|
||||
const ordinal = scene.sources.length + 1;
|
||||
return {
|
||||
id: uniqueId(`source-${ordinal}`, existingIds),
|
||||
name: `음원 ${ordinal}`,
|
||||
position: [0, 0, scene.image?.plane?.z ?? scene.sources[0]?.position?.[2] ?? 2],
|
||||
gainDb: -6,
|
||||
startTime: 0,
|
||||
duration: scene.duration,
|
||||
signal: { type: "tone", frequency: Math.min(1000, scene.sampleRate / 4), phaseDeg: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function changeSignalType(source, type, firstAudioAssetId = "") {
|
||||
if (type === "tone") {
|
||||
source.signal = { type: "tone", frequency: 1000, phaseDeg: 0 };
|
||||
} else if (type === "noise") {
|
||||
source.signal = { type: "noise", color: "white", seedOffset: 0 };
|
||||
} else if (type === "asset") {
|
||||
source.signal = { type: "asset", assetId: firstAudioAssetId, channel: 0, loop: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function describeSignal(signal) {
|
||||
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
||||
return "알 수 없는 신호";
|
||||
}
|
||||
|
||||
export function referencedAssetIds(scene) {
|
||||
const ids = new Set();
|
||||
if (scene.image?.assetId) ids.add(scene.image.assetId);
|
||||
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
|
||||
if (entry.signal?.type === "asset" && entry.signal.assetId) ids.add(entry.signal.assetId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function isAssetReferenced(scene, assetId) {
|
||||
return referencedAssetIds(scene).has(assetId);
|
||||
}
|
||||
|
||||
export function safeDownloadStem(scene) {
|
||||
return uniqueId(scene.id || scene.name || "scene", new Set());
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
.builder-shell {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.workspace-tools,
|
||||
.runtime-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.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: 0.82rem;
|
||||
font-weight: 720;
|
||||
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus-visible {
|
||||
border-color: var(--mint);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
border-color: rgb(101 242 191 / 55%);
|
||||
color: #06130f;
|
||||
background: var(--mint);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: rgb(255 255 255 / 3%);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
border-color: rgb(255 123 115 / 35%);
|
||||
color: var(--danger);
|
||||
background: rgb(255 123 115 / 7%);
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.builder-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(330px, 400px);
|
||||
min-height: calc(100vh - 65px);
|
||||
}
|
||||
|
||||
.scene-workspace {
|
||||
min-width: 0;
|
||||
padding: clamp(22px, 3vw, 42px);
|
||||
}
|
||||
|
||||
.workspace-heading,
|
||||
.source-strip-heading,
|
||||
.asset-shelf {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.workspace-heading {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.workspace-heading h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(1.8rem, 4vw, 3.2rem);
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
.workspace-heading .eyebrow {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.canvas-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 520px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: #0a1512;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
#scene-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: clamp(520px, 61vh, 720px);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-empty {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: min(430px, calc(100% - 48px));
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.canvas-empty strong {
|
||||
color: #cfe2db;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.canvas-empty strong,
|
||||
.canvas-empty span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.canvas-empty.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.canvas-badge {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 999px;
|
||||
color: #d7e8e2;
|
||||
background: rgb(8 17 15 / 78%);
|
||||
backdrop-filter: blur(10px);
|
||||
font: 650 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 28px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-strip-heading {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.source-strip-heading h2,
|
||||
.asset-shelf h2 {
|
||||
margin-bottom: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.source-strip-heading p,
|
||||
.asset-shelf p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.source-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 11px;
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 15px;
|
||||
color: inherit;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.source-card:hover,
|
||||
.source-card:focus-visible,
|
||||
.source-card.is-selected {
|
||||
border-color: var(--source-color, var(--mint));
|
||||
outline: none;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.source-card__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-card__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--source-color, var(--mint));
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--source-color, var(--mint)) 16%, transparent);
|
||||
}
|
||||
|
||||
.source-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-card small {
|
||||
color: var(--muted);
|
||||
font: 600 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.asset-shelf {
|
||||
align-items: flex-start;
|
||||
margin-top: 28px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.asset-shelf > div:first-child {
|
||||
max-width: 470px;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex: 1 1 320px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
padding: 8px 9px 8px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.asset-chip__label {
|
||||
overflow: hidden;
|
||||
max-width: 210px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.asset-chip__state {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.asset-chip__remove {
|
||||
display: grid;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-chip__remove:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
align-self: start;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 65px);
|
||||
padding: 18px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: rgb(12 24 21 / 86%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.runtime-line {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.control-section,
|
||||
.validation-card {
|
||||
padding: 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.control-section + .control-section,
|
||||
.validation-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-title > span {
|
||||
color: var(--mint);
|
||||
font: 750 0.68rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
background: #0b1714;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: var(--mint);
|
||||
outline: 2px solid rgb(101 242 191 / 11%);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.check-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.check-field input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: var(--mint);
|
||||
}
|
||||
|
||||
.field--wide + .field--wide,
|
||||
.field-grid + .field--wide,
|
||||
.field--wide + .field-grid,
|
||||
#tone-fields,
|
||||
#noise-fields,
|
||||
#asset-fields,
|
||||
.button--full {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.field-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 11px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.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: 0.82rem;
|
||||
}
|
||||
|
||||
.validation-card p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
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%);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.builder-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.builder-header,
|
||||
.workspace-heading,
|
||||
.source-strip-heading,
|
||||
.asset-shelf {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.header-actions .button,
|
||||
.workspace-tools .button {
|
||||
min-width: 0;
|
||||
flex: 1 1 calc(50% - 5px);
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.workspace-tools {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-workspace {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.canvas-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
#scene-canvas {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.field-grid--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user