diff --git a/apps/acoustic-camera/asset-runtime.js b/apps/acoustic-camera/asset-runtime.js
new file mode 100644
index 0000000..50833c0
--- /dev/null
+++ b/apps/acoustic-camera/asset-runtime.js
@@ -0,0 +1,261 @@
+import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
+import { sha256File } from "/packages/contracts/io.js";
+
+const MAX_FILE_BYTES = 128 * 1024 * 1024;
+const MAX_BOUND_FILE_BYTES = 512 * 1024 * 1024;
+
+function basename(path) {
+ return String(path).split(/[\\/]/).pop();
+}
+
+function kindForFile(file) {
+ if (file.type.startsWith("image/") || /\.(?:png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name)) return "image";
+ if (file.type.startsWith("audio/") || /\.(?:wav|flac|mp3|ogg|m4a|aac)$/i.test(file.name)) return "audio";
+ return null;
+}
+
+function referencedAssetIds(scene) {
+ const ids = new Set();
+ if (scene.image?.assetId) ids.add(scene.image.assetId);
+ for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
+ if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
+ }
+ return ids;
+}
+
+function audioAssetIds(scene) {
+ const ids = new Set();
+ for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
+ if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
+ }
+ return ids;
+}
+
+async function loadImageElement(file) {
+ const url = URL.createObjectURL(file);
+ const image = new Image();
+ image.decoding = "async";
+ try {
+ await new Promise((resolve, reject) => {
+ image.addEventListener("load", resolve, { once: true });
+ image.addEventListener("error", () => reject(new Error(`${file.name} 이미지를 decode하지 못했습니다.`)), {
+ once: true,
+ });
+ image.src = url;
+ });
+ return { image, url };
+ } catch (error) {
+ URL.revokeObjectURL(url);
+ throw error;
+ }
+}
+
+function waitForDecode(decodePromise, signal) {
+ if (!signal) return decodePromise;
+ if (signal.aborted) return Promise.reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
+ signal.addEventListener("abort", onAbort, { once: true });
+ decodePromise.then(
+ (value) => {
+ signal.removeEventListener("abort", onAbort);
+ resolve(value);
+ },
+ (error) => {
+ signal.removeEventListener("abort", onAbort);
+ reject(error);
+ },
+ );
+ });
+}
+
+export function createCameraAssetRuntime(onChange = () => {}) {
+ const files = new Map();
+ const errors = new Map();
+ let imageElement = null;
+ let imageUrl = null;
+ let generation = 0;
+
+ function revokeImage() {
+ if (imageUrl) URL.revokeObjectURL(imageUrl);
+ imageUrl = null;
+ imageElement = null;
+ }
+
+ function reset() {
+ generation += 1;
+ files.clear();
+ errors.clear();
+ revokeImage();
+ onChange();
+ }
+
+ function totalBoundBytes(exceptIds = new Set()) {
+ let total = 0;
+ const countedFiles = new Set();
+ for (const [assetId, file] of files) {
+ if (!exceptIds.has(assetId) && !countedFiles.has(file)) {
+ total += file.size;
+ countedFiles.add(file);
+ }
+ }
+ return total;
+ }
+
+ async function refreshImage(scene, expectedGeneration) {
+ const imageAssetId = scene.image?.assetId;
+ if (!imageAssetId || !files.has(imageAssetId)) {
+ revokeImage();
+ return;
+ }
+ const file = files.get(imageAssetId);
+ const loaded = await loadImageElement(file);
+ if (generation !== expectedGeneration) {
+ URL.revokeObjectURL(loaded.url);
+ return;
+ }
+ revokeImage();
+ imageElement = loaded.image;
+ imageUrl = loaded.url;
+ }
+
+ async function bindFiles(selectedFiles, scene) {
+ const currentGeneration = ++generation;
+ const descriptors = scene.assets ?? [];
+ const bound = [];
+ const rejected = [];
+ for (const file of selectedFiles) {
+ try {
+ if (file.size > MAX_FILE_BYTES) {
+ throw new Error(`${file.name} 크기가 128 MiB 파일 한도를 넘습니다.`);
+ }
+ const kind = kindForFile(file);
+ if (!kind) throw new Error(`${file.name}의 image/audio 종류를 판별할 수 없습니다.`);
+ const hash = await sha256File(file);
+ if (generation !== currentGeneration) return { bound, rejected, stale: true };
+ const candidates = descriptors.filter((descriptor) => descriptor.kind === kind);
+ let matches = candidates.filter((descriptor) => descriptor.sha256 === hash);
+ if (matches.length === 0) {
+ matches = candidates.filter(
+ (descriptor) => !descriptor.sha256 && basename(descriptor.fileName) === file.name,
+ );
+ }
+ if (matches.length === 0) {
+ const conflict = candidates.some(
+ (descriptor) => basename(descriptor.fileName) === file.name && descriptor.sha256 !== hash,
+ );
+ throw new Error(
+ conflict
+ ? `${file.name}의 SHA-256이 manifest와 다릅니다.`
+ : `${file.name}에 대응하는 ${kind} asset descriptor가 없습니다.`,
+ );
+ }
+ const replacingIds = new Set(matches.map((descriptor) => descriptor.id));
+ const nextTotal = totalBoundBytes(replacingIds) + file.size;
+ if (nextTotal > MAX_BOUND_FILE_BYTES) {
+ throw new Error(`연결 파일 합계가 512 MiB 한도를 넘습니다.`);
+ }
+ for (const descriptor of matches) {
+ descriptor.sha256 ??= hash;
+ files.set(descriptor.id, file);
+ errors.delete(descriptor.id);
+ bound.push(descriptor.id);
+ }
+ } catch (error) {
+ rejected.push({ fileName: file.name, message: error instanceof Error ? error.message : String(error) });
+ }
+ }
+ try {
+ await refreshImage(scene, currentGeneration);
+ } catch (error) {
+ if (scene.image?.assetId) errors.set(scene.image.assetId, error.message);
+ rejected.push({ fileName: files.get(scene.image?.assetId)?.name ?? "image", message: error.message });
+ }
+ if (generation === currentGeneration) onChange();
+ return { bound: [...new Set(bound)], rejected, stale: generation !== currentGeneration };
+ }
+
+ function statuses(scene) {
+ const referenced = referencedAssetIds(scene);
+ return (scene.assets ?? [])
+ .filter((descriptor) => referenced.has(descriptor.id))
+ .map((descriptor) => ({
+ descriptor,
+ file: files.get(descriptor.id) ?? null,
+ error: errors.get(descriptor.id) ?? null,
+ ready: files.has(descriptor.id) && !errors.has(descriptor.id),
+ }));
+ }
+
+ function missingAssetIds(scene) {
+ return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
+ }
+
+ async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
+ const ids = [...audioAssetIds(scene)];
+ const entries = [...scene.sources, ...(scene.ambient ?? [])];
+ const decoded = new Map();
+ const decodedByFile = new Map();
+ let retainedBytes = 0;
+ for (let index = 0; index < ids.length; index += 1) {
+ const assetId = ids[index];
+ const file = files.get(assetId);
+ if (!file) throw new Error(`오디오 원본 asset을 연결하세요: ${assetId}`);
+ onProgress({ completed: index, total: ids.length, assetId, fileName: file.name });
+ let audio = decodedByFile.get(file);
+ if (!audio) {
+ const remaining = MAX_DECODED_AUDIO_BYTES - retainedBytes;
+ audio = await waitForDecode(
+ decodeAudioFile(file, scene.sampleRate, remaining, signal),
+ signal,
+ );
+ decodedByFile.set(file, audio);
+ retainedBytes += audio.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
+ }
+ for (const entry of entries) {
+ if (entry.signal?.type !== "asset" || entry.signal.assetId !== assetId) continue;
+ const channel = entry.signal.channel ?? 0;
+ if (!audio.channels[channel]) {
+ throw new Error(
+ `${assetId}은 ${audio.channels.length}채널 오디오이므로 요청한 channel ${channel}을 사용할 수 없습니다.`,
+ );
+ }
+ }
+ decoded.set(assetId, audio);
+ }
+ onProgress({ completed: ids.length, total: ids.length });
+ return decoded;
+ }
+
+ function transferListForAudio(assetBuffers) {
+ const transfer = [];
+ const seen = new Set();
+ for (const audio of assetBuffers.values()) {
+ for (const channel of audio.channels) {
+ if (!seen.has(channel.buffer)) {
+ seen.add(channel.buffer);
+ transfer.push(channel.buffer);
+ }
+ }
+ }
+ return transfer;
+ }
+
+ return {
+ bindFiles,
+ decodeAudioAssets,
+ get imageElement() {
+ return imageElement;
+ },
+ missingAssetIds,
+ reset,
+ statuses,
+ transferListForAudio,
+ dispose() {
+ generation += 1;
+ revokeImage();
+ files.clear();
+ errors.clear();
+ },
+ };
+}
diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html
index 4150469..cd61735 100644
--- a/apps/acoustic-camera/index.html
+++ b/apps/acoustic-camera/index.html
@@ -3,30 +3,133 @@
-
+
Acoustic Camera · micarrsim
+
-
-