feat: edit moving source trajectories

This commit is contained in:
Codex
2026-08-28 16:04:55 +09:00
parent acb36bf15d
commit a20b55e8e2
8 changed files with 499 additions and 15 deletions
+10 -2
View File
@@ -20,8 +20,12 @@
- `Tone`: 주파수와 위상을 갖는 합성 정현파
- `Noise`: seed로 재현되는 white 또는 pink noise
- `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능
6. 필요하면 ambient 종류와 gain을 설정합니다.
7. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
6. 움직이는 음원은 **시간에 따라 음원 이동**을 켭니다. 현재 위치에서 시작해 Scene 끝까지 이어지는
기본 두 점 경로가 생성됩니다. 경로점의 절대 시간과 x/y/z를 수정하거나 점을 추가·삭제할 수 있습니다.
7. **경로 미리보기** slider로 시간별 위치를 확인합니다. 번호를 눌러 경로점을 선택한 뒤 canvas를
클릭하면 선택한 점의 x/y가 바뀝니다. 첫 경로점은 고정 장면 호환용 `source.position`에도 동기화됩니다.
8. 필요하면 ambient 종류와 gain을 설정합니다.
9. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
오디오 파일은 PCM WAV가 가장 안정적입니다. MP3, FLAC, OGG, M4A 등의 실제 decode 지원 여부는
브라우저와 운영체제 codec에 따라 달라지며, 지원하지 않는 파일은 미리 듣기 시 오류로 표시됩니다.
@@ -69,3 +73,7 @@ id, 종류, 파일명과 SHA-256이 들어갑니다.
Builder의 mix는 재현 가능한 source recipe와 위치 ground truth를 만드는 단계입니다. 자유음장 전파,
마이크 calibration과 DAS는 Acoustic Camera에서 수행합니다. 실제 방의 반사·잔향·차폐를 Builder가
추정하지 않습니다.
이동 경로는 Scene 시작 기준의 keyframe을 직선 보간합니다. Builder의 mono 미리 듣기는 공간 위치를
소리로 표현하지 않으므로, 움직임은 canvas에서 확인하고 실제 시간창별 위치 추정은 Acoustic Camera에서
실행합니다. 경로점 사이의 Doppler 효과나 연속적인 전파 시간 변화는 현재 모델 범위에 포함되지 않습니다.
+63 -8
View File
@@ -1,4 +1,5 @@
import { SOURCE_COLORS } from "./model.js";
import { sourcePositionAtTime } from "/packages/acoustics-core/trajectory.js";
function finitePlane(plane) {
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) &&
@@ -45,9 +46,50 @@ function drawGrid(context, width, height, plane) {
context.textAlign = "left";
}
function drawSource(context, source, index, selected, plane, width, height) {
if (!source.position?.every(Number.isFinite)) return;
const point = coordinateToPixel(source.position, plane, width, height);
function drawTrajectory(context, source, index, selected, selectedKeyframeIndex, plane, width, height) {
if (!Array.isArray(source.trajectory) || source.trajectory.length < 2) return;
const points = source.trajectory.map(({ position }) => coordinateToPixel(position, plane, width, height));
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save();
context.strokeStyle = color;
context.globalAlpha = selected ? 0.85 : 0.28;
context.lineWidth = selected ? 2.5 : 1.5;
context.setLineDash([7, 5]);
context.beginPath();
points.forEach((point, pointIndex) => {
if (pointIndex === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.stroke();
context.setLineDash([]);
points.forEach((point, pointIndex) => {
context.globalAlpha = selected ? 1 : 0.42;
context.fillStyle = color;
context.beginPath();
context.arc(point.x, point.y, pointIndex === selectedKeyframeIndex && selected ? 6 : 4, 0, Math.PI * 2);
context.fill();
if (pointIndex === selectedKeyframeIndex && selected) {
context.strokeStyle = "#fff";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 9, 0, Math.PI * 2);
context.stroke();
}
});
context.restore();
}
function positionAtPreviewTime(source, previewTime) {
try {
return sourcePositionAtTime(source, previewTime);
} catch {
return source.position;
}
}
function drawSource(context, source, position, index, selected, plane, width, height) {
if (!position?.every(Number.isFinite)) return;
const point = coordinateToPixel(position, plane, width, height);
if (point.x < -30 || point.x > width + 30 || point.y < -30 || point.y > height + 30) return;
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save();
@@ -81,8 +123,8 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
const context = canvas.getContext("2d");
let current = null;
function render(scene, image, plane, selectedSourceId) {
current = { scene, image, plane, selectedSourceId };
function render(scene, image, plane, selectedSourceId, previewTime = 0, selectedKeyframeIndex = 0) {
current = { scene, image, plane, selectedSourceId, previewTime, selectedKeyframeIndex };
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
@@ -111,12 +153,22 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
context.fillRect(0, 0, width, height);
}
scene.sources.forEach((source, index) => {
drawSource(context, source, index, source.id === selectedSourceId, plane, width, height);
drawTrajectory(
context, source, index, source.id === selectedSourceId,
source.id === selectedSourceId ? selectedKeyframeIndex : -1,
plane, width, height,
);
});
scene.sources.forEach((source, index) => {
drawSource(
context, source, positionAtPreviewTime(source, previewTime), index,
source.id === selectedSourceId, plane, width, height,
);
});
emptyElement.classList.toggle("is-hidden", Boolean(image));
badgeElement.textContent =
`x ${plane.xMin.toFixed(2)}…${plane.xMax.toFixed(2)} m · ` +
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m`;
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m · t ${previewTime.toFixed(2)} s`;
}
canvas.addEventListener("pointerdown", (event) => {
@@ -128,7 +180,10 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
});
new ResizeObserver(() => {
if (current) render(current.scene, current.image, current.plane, current.selectedSourceId);
if (current) render(
current.scene, current.image, current.plane, current.selectedSourceId,
current.previewTime, current.selectedKeyframeIndex,
);
}).observe(canvas);
return { render };
+13
View File
@@ -133,6 +133,19 @@
<label class="field"><span>시작 (s)</span><input id="source-start" type="number" min="0" step="0.01" /></label>
<label class="field"><span>길이 (s)</span><input id="source-duration" type="number" min="0.01" step="0.01" /></label>
</div>
<section class="trajectory-editor" aria-label="음원 이동 경로">
<label class="check-field trajectory-toggle"><input id="source-motion" type="checkbox" /><span>시간에 따라 음원 이동</span></label>
<p class="section-note">경로 시간은 Scene 시작 기준입니다. 이동을 켜면 현재 위치에서 시작해 Scene 끝까지 이어지는 두 점 경로가 만들어집니다.</p>
<div id="trajectory-fields" class="is-hidden">
<label class="field field--wide trajectory-preview">
<span>경로 미리보기 · <output id="preview-time-value" for="preview-time">0.00 s</output></span>
<input id="preview-time" type="range" min="0" max="1" step="0.01" value="0" />
</label>
<p class="section-note">경로점을 선택하면 canvas 클릭으로 그 점의 x/y를 옮길 수 있습니다. 흰 테두리 점은 현재 선택한 경로점입니다.</p>
<div class="trajectory-list" id="trajectory-list"></div>
<button class="button button--ghost button--full" id="add-keyframe-button" type="button">+ 경로점 추가</button>
</div>
</section>
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
+178 -5
View File
@@ -6,17 +6,26 @@ 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) => {
@@ -34,6 +43,7 @@ const elements = Object.fromEntries(
"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",
@@ -44,6 +54,8 @@ elements.validationCard = document.querySelector(".validation-card");
const state = {
scene: null,
selectedSourceId: null,
selectedKeyframeIndex: 0,
previewTime: 0,
viewPlane: { ...DEFAULT_VIEW_PLANE },
imageElement: null,
imageUrl: null,
@@ -70,8 +82,10 @@ const canvasView = createSceneCanvas(
({ x, y }) => {
const source = selectedSource();
if (!source) return;
source.position[0] = Number(x.toFixed(4));
source.position[1] = Number(y.toFixed(4));
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();
@@ -95,6 +109,7 @@ function createAssetBackedSource(descriptor, file) {
changeSignalType(source, "asset", descriptor.id);
state.scene.sources.push(source);
state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
return source;
}
@@ -137,7 +152,10 @@ function syncImagePlane() {
function renderCanvas() {
if (!state.scene) return;
canvasView.render(state.scene, state.imageElement, state.viewPlane, state.selectedSourceId);
canvasView.render(
state.scene, state.imageElement, state.viewPlane, state.selectedSourceId,
state.previewTime, state.selectedKeyframeIndex,
);
}
function renderSourceCards() {
@@ -159,10 +177,12 @@ function renderSourceCards() {
const signal = document.createElement("small");
signal.textContent = describeSignal(source.signal);
const position = document.createElement("small");
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m`;
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.append(heading, signal, position);
button.addEventListener("click", () => {
state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
renderSourceCards();
syncSourceEditor();
renderCanvas();
@@ -193,6 +213,104 @@ function fillAudioSelect(select, selectedId, placeholder) {
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));
@@ -206,6 +324,7 @@ function syncSourceEditor() {
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");
@@ -532,7 +651,15 @@ function bindSceneControls() {
elements["workspace-title"].textContent = input.value || "이름 없는 장면";
});
bindInput("scene-id", (input) => { state.scene.id = input.value; });
bindInput("scene-duration", (input) => { state.scene.duration = numberFrom(input); });
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();
@@ -571,6 +698,7 @@ function bindSourceControls() {
const source = createSource(state.scene);
state.scene.sources.push(source);
state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
renderSourceCards();
syncSourceEditor();
renderCanvas();
@@ -583,6 +711,7 @@ function bindSourceControls() {
}
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();
@@ -593,7 +722,12 @@ function bindSourceControls() {
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();
});
}
@@ -611,6 +745,41 @@ function bindSourceControls() {
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 } = {}) {
@@ -784,6 +953,8 @@ function bindFileControls() {
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,
@@ -851,6 +1022,8 @@ async function initialize() {
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,
+112
View File
@@ -7,11 +7,123 @@ export const DEFAULT_VIEW_PLANE = Object.freeze({
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;
}
+79
View File
@@ -458,6 +458,76 @@
line-height: 1.5;
}
.trajectory-editor {
margin: 12px 0;
padding: 12px;
border: 1px solid rgb(101 242 191 / 16%);
border-radius: 12px;
background: rgb(101 242 191 / 3%);
}
.trajectory-toggle {
min-height: 28px;
}
.trajectory-preview {
margin-top: 12px;
}
.trajectory-preview output {
color: var(--mint);
font: 700 0.7rem ui-monospace, SFMono-Regular, Consolas, monospace;
}
.trajectory-preview input[type="range"] {
min-height: 28px;
padding: 0;
accent-color: var(--mint);
}
.trajectory-list {
display: grid;
gap: 8px;
margin: 12px 0 10px;
}
.trajectory-row {
display: grid;
grid-template-columns: 42px repeat(4, minmax(0, 1fr)) 30px;
gap: 5px;
align-items: end;
}
.trajectory-row__select,
.trajectory-row__remove {
min-height: 36px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--muted);
background: #0b1714;
cursor: pointer;
}
.trajectory-row__select.is-selected {
border-color: var(--mint);
color: var(--mint);
}
.trajectory-row__remove:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.trajectory-row .field > span {
font-size: 0.6rem;
}
.trajectory-row .field input {
min-height: 36px;
padding: 6px;
}
.is-hidden {
display: none !important;
}
@@ -563,4 +633,13 @@
.field-grid--three {
grid-template-columns: 1fr;
}
.trajectory-row {
grid-template-columns: 38px repeat(2, minmax(0, 1fr)) 30px;
}
.trajectory-row .field:nth-of-type(3),
.trajectory-row .field:nth-of-type(4) {
grid-row: 2;
}
}