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 { createSplitPane } from "/packages/ui/split-pane.js"; import { createAudioRuntime } from "./audio-runtime.js"; import { createSceneCanvas } from "./canvas-view.js"; import { DEFAULT_VIEW_PLANE, MAX_TRAJECTORY_KEYFRAMES, SOURCE_COLORS, assetDescriptorForFile, changeSignalType, createSceneId, createSource, describeSignal, disableSourceTrajectory, enableSourceTrajectory, insertTrajectoryKeyframe, isAssetReferenced, matchAssetDescriptor, prepareSceneForEditing, removeTrajectoryKeyframe, referencedAssetIds, safeDownloadStem, scaleSceneTrajectories, synchronizeSourcePosition, trajectoryTimeBounds, updateTrajectoryKeyframeTime, } 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", "audio-source-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", "source-motion", "trajectory-fields", "trajectory-list", "preview-time", "preview-time-value", "add-keyframe-button", "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 layoutSplitPane = createSplitPane({ container: byId("builder-layout"), separator: byId("builder-split-handle"), panel: byId("builder-controls"), panelSide: "end", storageKey: "micarrsim.builder.control-panel-width", defaultSize: 430, minSize: 360, maxSize: 720, minMainSize: 560, enabledMediaQuery: "(min-width: 1101px)", }); const state = { scene: null, selectedSourceId: null, selectedKeyframeIndex: 0, previewTime: 0, 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; const target = source.trajectory?.[state.selectedKeyframeIndex]?.position ?? source.position; target[0] = Number(x.toFixed(4)); target[1] = Number(y.toFixed(4)); synchronizeSourcePosition(source); syncSourceEditor(); markChanged("위치 변경됨"); renderSourceCards(); renderCanvas(); }, ); function selectedSource() { return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null; } function sourceNameForAudioFile(file, fallback) { const stem = file.name.replace(/\.[^.]+$/, "").trim(); return (stem || fallback).slice(0, 120); } function createAssetBackedSource(descriptor, file) { if (state.scene.sources.length >= 32) return null; const source = createSource(state.scene); source.name = sourceNameForAudioFile(file, source.name); changeSignalType(source, "asset", descriptor.id); state.scene.sources.push(source); state.selectedSourceId = source.id; state.selectedKeyframeIndex = 0; return source; } 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, state.previewTime, state.selectedKeyframeIndex, ); } 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; name.title = source.name; heading.append(dot, name); const signal = document.createElement("small"); signal.textContent = describeSignal(source.signal, state.scene.sampleRate); const position = document.createElement("small"); const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : ""; position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`; button.title = [source.name, signal.textContent, position.textContent].join("\n"); button.append(heading, signal, position); button.addEventListener("click", () => { state.selectedSourceId = source.id; state.selectedKeyframeIndex = 0; 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 syncPreviewTime() { const maximum = Math.max(0, Number(state.scene?.duration) || 0); state.previewTime = Math.max(0, Math.min(maximum, state.previewTime)); elements["preview-time"].max = String(maximum); elements["preview-time"].step = String(Math.max(0.001, Math.min(0.01, maximum / 100 || 0.01))); elements["preview-time"].value = String(state.previewTime); elements["preview-time-value"].value = `${state.previewTime.toFixed(2)} s`; elements["preview-time-value"].textContent = `${state.previewTime.toFixed(2)} s`; } function trajectoryNumberField(label, value, options, onChange) { const field = document.createElement("label"); field.className = "field"; const title = document.createElement("span"); title.textContent = label; const input = document.createElement("input"); input.type = "number"; input.value = String(value); input.step = String(options.step ?? 0.01); if (Number.isFinite(options.min)) input.min = String(options.min); if (Number.isFinite(options.max)) input.max = String(options.max); input.addEventListener("change", () => { if (!Number.isFinite(input.valueAsNumber)) { input.value = String(value); return; } onChange(input.valueAsNumber); }); field.append(title, input); return field; } function renderTrajectoryEditor(source) { const moving = Array.isArray(source.trajectory); elements["source-motion"].checked = moving; elements["trajectory-fields"].classList.toggle("is-hidden", !moving); syncPreviewTime(); const list = elements["trajectory-list"]; list.replaceChildren(); if (!moving) return; state.selectedKeyframeIndex = Math.max(0, Math.min(source.trajectory.length - 1, state.selectedKeyframeIndex)); source.trajectory.forEach((keyframe, index) => { const row = document.createElement("div"); row.className = "trajectory-row"; row.dataset.keyframeIndex = String(index); const select = document.createElement("button"); select.type = "button"; select.className = "trajectory-row__select"; select.classList.toggle("is-selected", index === state.selectedKeyframeIndex); select.textContent = String(index + 1); select.title = `${index + 1}번 경로점 선택`; select.addEventListener("click", () => { state.selectedKeyframeIndex = index; state.previewTime = keyframe.time; renderTrajectoryEditor(source); renderCanvas(); }); const bounds = trajectoryTimeBounds(source, index, state.scene.duration); const time = trajectoryNumberField("t (s)", keyframe.time, { min: bounds.minimum, max: bounds.maximum }, (value) => { state.previewTime = updateTrajectoryKeyframeTime(source, index, value, state.scene.duration); syncSourceEditor(); renderCanvas(); markChanged("경로 시간 변경됨"); }); const coordinates = ["x", "y", "z"].map((axis, coordinateIndex) => trajectoryNumberField(`${axis} (m)`, keyframe.position[coordinateIndex], { min: coordinateIndex === 2 ? 0.01 : undefined, step: coordinateIndex === 2 ? 0.1 : 0.01, }, (value) => { keyframe.position[coordinateIndex] = value; synchronizeSourcePosition(source); state.previewTime = keyframe.time; syncSourceEditor(); renderSourceCards(); renderCanvas(); markChanged("경로 위치 변경됨"); }), ); const remove = document.createElement("button"); remove.type = "button"; remove.className = "trajectory-row__remove"; remove.textContent = "×"; remove.title = "경로점 삭제"; remove.disabled = source.trajectory.length <= 2; remove.addEventListener("click", () => { if (!removeTrajectoryKeyframe(source, index)) return; state.selectedKeyframeIndex = Math.min(state.selectedKeyframeIndex, source.trajectory.length - 1); syncSourceEditor(); renderSourceCards(); renderCanvas(); markChanged("경로점 삭제됨"); }); row.append(select, time, ...coordinates, remove); list.append(row); }); elements["add-keyframe-button"].disabled = source.trajectory.length >= MAX_TRAJECTORY_KEYFRAMES; } 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; renderTrajectoryEditor(source); 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}`; 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)); label.title = [asset.fileName, asset.id, asset.sha256].filter(Boolean).join(" · "); 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) => { const previousDuration = state.scene.duration; const nextDuration = numberFrom(input); state.scene.duration = nextDuration; scaleSceneTrajectories(state.scene, previousDuration, nextDuration); syncPreviewTime(); syncSourceEditor(); renderCanvas(); }); 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(); renderSourceCards(); }, "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; state.selectedKeyframeIndex = 0; 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; state.selectedKeyframeIndex = 0; 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); if (selectedSource().trajectory) { selectedSource().trajectory[0].position[index] = numberFrom(input); synchronizeSourcePosition(selectedSource()); } renderSourceCards(); renderTrajectoryEditor(selectedSource()); 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"); elements["source-motion"].addEventListener("change", () => { const source = selectedSource(); if (elements["source-motion"].checked) { enableSourceTrajectory(source, state.scene.duration, state.viewPlane); state.selectedKeyframeIndex = 0; state.previewTime = 0; } else { disableSourceTrajectory(source); state.selectedKeyframeIndex = 0; } syncSourceEditor(); renderSourceCards(); renderCanvas(); markChanged(elements["source-motion"].checked ? "이동 경로 켜짐" : "이동 경로 꺼짐"); }); elements["preview-time"].addEventListener("input", () => { state.previewTime = numberFrom(elements["preview-time"]); syncPreviewTime(); renderCanvas(); }); elements["add-keyframe-button"].addEventListener("click", () => { const source = selectedSource(); const insertedIndex = insertTrajectoryKeyframe(source); if (insertedIndex < 0) { setStatus(`경로점은 최대 ${MAX_TRAJECTORY_KEYFRAMES.toLocaleString()}개까지 추가할 수 있습니다.`); return; } state.selectedKeyframeIndex = insertedIndex; state.previewTime = source.trajectory[insertedIndex].time; syncSourceEditor(); renderSourceCards(); renderCanvas(); markChanged("경로점 추가됨"); }); } async function handleAudioFiles(files, { createSources = false } = {}) { const epoch = state.assetEpoch; let attachedCount = 0; let createdCount = 0; let lastError = null; for (const file of files) { if (epoch !== state.assetEpoch) return; if (createSources && state.scene.sources.length >= 32) { lastError = new Error("음원은 최대 32개까지 추가할 수 있습니다."); break; } try { const descriptor = await attachFile(file, "audio", epoch); if (!descriptor) return; attachedCount += 1; if (createSources && createAssetBackedSource(descriptor, file)) createdCount += 1; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } } if (epoch !== state.assetEpoch) return; if (attachedCount > 0) { renderAssetList(); renderSourceCards(); syncSourceEditor(); syncAmbientEditor(); renderCanvas(); const status = createSources ? `${createdCount}개 오디오 파일 음원 추가됨` : `${attachedCount}개 원본 오디오 연결됨`; markChanged(lastError ? `${status} · ${lastError.message}` : status); } else if (lastError) { setStatus(lastError.message); } } 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 = ""; if (files.length > 0) await handleAudioFiles(files); }); elements["audio-source-input"].addEventListener("change", async (event) => { const files = [...event.target.files]; event.target.value = ""; if (files.length > 0) await handleAudioFiles(files, { createSources: true }); }); 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.selectedKeyframeIndex = 0; state.previewTime = 0; 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; state.selectedKeyframeIndex = 0; state.previewTime = 0; 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(); layoutSplitPane.destroy(); }); 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("초기화 실패"); });