Files

1078 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
import { downloadBlob, downloadJson, readJsonFile, sha256File } from "/packages/contracts/io.js";
import { validateScene } from "/packages/contracts/validation.js";
import { 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("초기화 실패");
});