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); } function missingAudioAssetIds(scene) { const audioIds = audioAssetIds(scene); return statuses(scene) .filter( (status) => audioIds.has(status.descriptor.id) && !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, missingAudioAssetIds, reset, statuses, transferListForAudio, dispose() { generation += 1; revokeImage(); files.clear(); errors.clear(); }, }; }