218 lines
8.0 KiB
JavaScript
218 lines
8.0 KiB
JavaScript
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());
|
|
}
|