846 lines
33 KiB
JavaScript
846 lines
33 KiB
JavaScript
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("초기화 실패");
|
||
});
|