Files
micarrsim/apps/dataset-builder/main.js
T
2026-08-28 10:46:33 +09:00

846 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("초기화 실패");
});