export const DEFAULT_VIEW_PLANE = Object.freeze({ xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45, }); export const SOURCE_COLORS = ["#65f2bf", "#58c7e8", "#ffc56e", "#ff8f87", "#be9bff", "#8ee36b"]; export const MAX_TRAJECTORY_KEYFRAMES = 4096; function copyPosition(position) { return position.map((coordinate) => Number(coordinate)); } function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); } export function synchronizeSourcePosition(source) { if (Array.isArray(source.trajectory) && source.trajectory.length > 0) { source.position = copyPosition(source.trajectory[0].position); } return source.position; } export function createDefaultTrajectory(source, sceneDuration, plane = DEFAULT_VIEW_PLANE) { const start = copyPosition(source.position); const width = Number.isFinite(plane?.xMax - plane?.xMin) && plane.xMax > plane.xMin ? plane.xMax - plane.xMin : DEFAULT_VIEW_PLANE.xMax - DEFAULT_VIEW_PLANE.xMin; const minimum = Number.isFinite(plane?.xMin) ? plane.xMin : DEFAULT_VIEW_PLANE.xMin; const maximum = Number.isFinite(plane?.xMax) ? plane.xMax : DEFAULT_VIEW_PLANE.xMax; const offset = Math.max(0.1, width * 0.25); const rightRoom = maximum - start[0]; const leftRoom = start[0] - minimum; const direction = rightRoom >= offset || rightRoom >= leftRoom ? 1 : -1; const endX = clamp(start[0] + direction * offset, minimum, maximum); return [ { time: 0, position: start }, { time: sceneDuration, position: [endX, start[1], start[2]] }, ]; } export function enableSourceTrajectory(source, sceneDuration, plane = DEFAULT_VIEW_PLANE) { if (!Array.isArray(source.trajectory) || source.trajectory.length < 2) { source.trajectory = createDefaultTrajectory(source, sceneDuration, plane); } synchronizeSourcePosition(source); return source.trajectory; } export function disableSourceTrajectory(source) { synchronizeSourcePosition(source); delete source.trajectory; } export function insertTrajectoryKeyframe(source) { if (!Array.isArray(source.trajectory) || source.trajectory.length < 2 || source.trajectory.length >= MAX_TRAJECTORY_KEYFRAMES) { return -1; } let leftIndex = 0; let largestGap = -Infinity; for (let index = 0; index < source.trajectory.length - 1; index += 1) { const gap = source.trajectory[index + 1].time - source.trajectory[index].time; if (gap > largestGap) { largestGap = gap; leftIndex = index; } } if (!(largestGap > 0)) return -1; const left = source.trajectory[leftIndex]; const right = source.trajectory[leftIndex + 1]; source.trajectory.splice(leftIndex + 1, 0, { time: left.time + largestGap / 2, position: left.position.map((coordinate, index) => coordinate + (right.position[index] - coordinate) / 2, ), }); synchronizeSourcePosition(source); return leftIndex + 1; } export function removeTrajectoryKeyframe(source, index) { if (!Array.isArray(source.trajectory) || source.trajectory.length <= 2 || !Number.isInteger(index) || index < 0 || index >= source.trajectory.length) return false; source.trajectory.splice(index, 1); synchronizeSourcePosition(source); return true; } export function trajectoryTimeBounds(source, index, sceneDuration) { const trajectory = source.trajectory; const duration = Number.isFinite(sceneDuration) && sceneDuration > 0 ? sceneDuration : 0; const gap = Math.max(1e-9, duration * 1e-9); return { minimum: index === 0 ? 0 : trajectory[index - 1].time + gap, maximum: index === trajectory.length - 1 ? duration : trajectory[index + 1].time - gap, }; } export function updateTrajectoryKeyframeTime(source, index, requestedTime, sceneDuration) { if (!Number.isFinite(requestedTime) || !source.trajectory?.[index]) return source.trajectory?.[index]?.time; const { minimum, maximum } = trajectoryTimeBounds(source, index, sceneDuration); source.trajectory[index].time = clamp(requestedTime, minimum, maximum); return source.trajectory[index].time; } export function scaleSceneTrajectories(scene, previousDuration, nextDuration) { if (!Number.isFinite(previousDuration) || previousDuration <= 0 || !Number.isFinite(nextDuration) || nextDuration <= 0) return; const scale = nextDuration / previousDuration; for (const source of scene.sources) { if (!Array.isArray(source.trajectory)) continue; source.trajectory.forEach((keyframe) => { keyframe.time *= scale; }); synchronizeSourcePosition(source); } } export function prepareSceneForEditing(scene) { const prepared = structuredClone(scene); prepared.assets ??= []; prepared.ambient ??= []; prepared.mix ??= { normalization: "none", targetPeak: 0.95 }; prepared.sources.forEach(synchronizeSourcePosition); return prepared; } export function createSceneId(now = Date.now(), randomValue = crypto.getRandomValues(new Uint32Array(1))[0]) { return `scene-${now.toString(36)}-${randomValue.toString(36)}`; } export function uniqueId(base, existingIds) { const normalized = String(base) .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9._-]+/g, "-") .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "asset"; let candidate = normalized; let suffix = 2; while (existingIds.has(candidate)) { candidate = `${normalized}-${suffix}`; suffix += 1; } return candidate; } export function assetDescriptorForFile(file, kind, existingIds) { const baseName = file.name.replace(/\.[^.]+$/, "") || kind; return { id: uniqueId(`${kind}-${baseName}`, existingIds), kind, fileName: file.name, mimeType: file.type || (kind === "image" ? "image/*" : "audio/*"), }; } export function matchAssetDescriptor(assets, boundAssetIds, fileName, kind, hash) { const candidates = assets.filter((asset) => asset.kind === kind); const hashMatches = candidates.filter((asset) => asset.sha256 === hash); const hashMatch = hashMatches.find((asset) => !boundAssetIds.has(asset.id)) ?? hashMatches[0]; if (hashMatch) return { descriptor: hashMatch, conflict: false }; const baseName = (path) => path.split(/[\\/]/).pop(); const nameCandidates = candidates.filter((asset) => baseName(asset.fileName) === fileName); const compatible = nameCandidates.find((asset) => !asset.sha256 && !boundAssetIds.has(asset.id)); if (compatible) return { descriptor: compatible, conflict: false }; return { descriptor: null, conflict: nameCandidates.some((asset) => asset.sha256 !== hash) }; } export function createSource(scene) { const existingIds = new Set(scene.sources.map((source) => source.id)); const ordinal = scene.sources.length + 1; return { id: uniqueId(`source-${ordinal}`, existingIds), name: `음원 ${ordinal}`, position: [0, 0, scene.image?.plane?.z ?? scene.sources[0]?.position?.[2] ?? 2], gainDb: -6, startTime: 0, duration: scene.duration, signal: { type: "tone", frequency: Math.min(1000, scene.sampleRate / 4), phaseDeg: 0 }, }; } export function changeSignalType(source, type, firstAudioAssetId = "") { if (type === "tone") { source.signal = { type: "tone", frequency: 1000, phaseDeg: 0 }; } else if (type === "noise") { source.signal = { type: "noise", color: "white", seedOffset: 0 }; } else if (type === "asset") { source.signal = { type: "asset", assetId: firstAudioAssetId, channel: 0, loop: false }; } } export function describeSignal(signal) { if (signal.type === "tone") return `${signal.frequency} Hz tone`; if (signal.type === "noise") return `${signal.color} noise`; if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`; return "알 수 없는 신호"; } export 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" && entry.signal.assetId) ids.add(entry.signal.assetId); } return ids; } export function isAssetReferenced(scene, assetId) { return referencedAssetIds(scene).has(assetId); } export function safeDownloadStem(scene) { return uniqueId(scene.id || scene.name || "scene", new Set()); }