feat: build reproducible dataset editor

This commit is contained in:
Codex
2026-08-28 10:46:33 +09:00
parent 6a1197d60e
commit 08fcf578de
16 changed files with 2579 additions and 11 deletions
+102
View File
@@ -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,
};
}
+135
View File
@@ -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
View File
@@ -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>
+845
View File
@@ -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("초기화 실패");
});
+14
View File
@@ -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),
});
}
});
+105
View File
@@ -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());
}
+551
View File
@@ -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;
}
}