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
+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("초기화 실패");
});