diff --git a/apps/dataset-builder/README.md b/apps/dataset-builder/README.md
index e6032b7..7d576d3 100644
--- a/apps/dataset-builder/README.md
+++ b/apps/dataset-builder/README.md
@@ -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 효과나 연속적인 전파 시간 변화는 현재 모델 범위에 포함되지 않습니다.
diff --git a/apps/dataset-builder/canvas-view.js b/apps/dataset-builder/canvas-view.js
index db60449..9d91fef 100644
--- a/apps/dataset-builder/canvas-view.js
+++ b/apps/dataset-builder/canvas-view.js
@@ -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 };
diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html
index 6fc5191..db6222f 100644
--- a/apps/dataset-builder/index.html
+++ b/apps/dataset-builder/index.html
@@ -133,6 +133,19 @@
+
+
+ 경로 시간은 Scene 시작 기준입니다. 이동을 켜면 현재 위치에서 시작해 Scene 끝까지 이어지는 두 점 경로가 만들어집니다.
+
+
+
경로점을 선택하면 canvas 클릭으로 그 점의 x/y를 옮길 수 있습니다. 흰 테두리 점은 현재 선택한 경로점입니다.
+
+
+
+
diff --git a/apps/dataset-builder/main.js b/apps/dataset-builder/main.js
index 558e2f2..7136b59 100644
--- a/apps/dataset-builder/main.js
+++ b/apps/dataset-builder/main.js
@@ -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,
diff --git a/apps/dataset-builder/model.js b/apps/dataset-builder/model.js
index 1ba8e0a..f43e842 100644
--- a/apps/dataset-builder/model.js
+++ b/apps/dataset-builder/model.js
@@ -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;
}
diff --git a/apps/dataset-builder/styles.css b/apps/dataset-builder/styles.css
index 7ee686c..c77a85a 100644
--- a/apps/dataset-builder/styles.css
+++ b/apps/dataset-builder/styles.css
@@ -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;
+ }
}
diff --git a/tests/integration/builder-ui.test.js b/tests/integration/builder-ui.test.js
index bf9b5cf..69efd91 100644
--- a/tests/integration/builder-ui.test.js
+++ b/tests/integration/builder-ui.test.js
@@ -100,6 +100,16 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
assertNear(Number(documentObject.querySelector("#source-x").value), 0.3, 0.002, "canvas x");
assertNear(Number(documentObject.querySelector("#source-y").value), 0.225, 0.002, "canvas y");
+ dispatchChecked(windowObject, documentObject.querySelector("#source-motion"), true);
+ assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "default trajectory points");
+ assertEqual(documentObject.querySelector(".trajectory-row input").value, "0", "trajectory starts at zero");
+ documentObject.querySelector("#add-keyframe-button").click();
+ assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 3, "keyframe added");
+ documentObject.querySelectorAll(".trajectory-row")[1].querySelector(".trajectory-row__remove").click();
+ assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "keyframe removed");
+ dispatchValue(windowObject, documentObject.querySelector("#preview-time"), "0.1");
+ assertEqual(documentObject.querySelector("#preview-time-value").textContent, "0.10 s", "preview time label");
+
dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
@@ -122,6 +132,9 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
const firstScene = JSON.parse(firstJsonText);
assertEqual(firstScene.sources.length, 2, "exported source count");
assertEqual(firstScene.sources[1].signal.loop, true, "source loop exported");
+ assertEqual(firstScene.sources[1].trajectory.length, 2, "trajectory exported");
+ assertEqual(firstScene.sources[1].trajectory[0].position.join(","), firstScene.sources[1].position.join(","), "fallback position synced");
+ assertEqual(firstScene.sources[1].trajectory[1].time, firstScene.duration, "trajectory spans scene");
assertEqual(firstScene.ambient[0].signal.loop, true, "ambient loop exported");
assertEqual(firstScene.image.widthPixels, 640, "image width exported");
assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported");
diff --git a/tests/unit/builder.test.js b/tests/unit/builder.test.js
index 63dbb3b..394e3ad 100644
--- a/tests/unit/builder.test.js
+++ b/tests/unit/builder.test.js
@@ -1,11 +1,17 @@
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
import {
assetDescriptorForFile,
+ disableSourceTrajectory,
+ enableSourceTrajectory,
createSceneId,
createSource,
+ insertTrajectoryKeyframe,
matchAssetDescriptor,
prepareSceneForEditing,
+ removeTrajectoryKeyframe,
referencedAssetIds,
+ scaleSceneTrajectories,
+ updateTrajectoryKeyframeTime,
} from "/apps/dataset-builder/model.js";
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
import {
@@ -43,6 +49,31 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
});
+test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
+ const source = { position: [0.5, 0.1, 2] };
+ const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
+ assertEqual(trajectory.length, 2, "default keyframe count");
+ assertEqual(trajectory[0].time, 0, "path starts at scene start");
+ assertEqual(trajectory[1].time, 2, "path spans scene duration");
+ assert(trajectory[1].position[0] < trajectory[0].position[0], "edge path turns inward");
+
+ const inserted = insertTrajectoryKeyframe(source);
+ assertEqual(inserted, 1, "midpoint insertion index");
+ assertNear(source.trajectory[1].time, 1, 1e-12, "midpoint time");
+ assertNear(source.trajectory[1].position[0], 0.35, 1e-12, "midpoint x");
+ assertNear(updateTrajectoryKeyframeTime(source, 1, 3, 2), 1.999999998, 1e-12, "ordered time clamp");
+
+ source.trajectory[0].position = [-0.25, 0.2, 1.5];
+ scaleSceneTrajectories({ sources: [source] }, 2, 4);
+ assertNear(source.trajectory[1].time, 3.999999996, 1e-9, "duration scales keyframes");
+ assertEqual(source.position.join(","), "-0.25,0.2,1.5", "first point fallback sync");
+ assert(removeTrajectoryKeyframe(source, 1), "remove optional midpoint");
+ assertEqual(removeTrajectoryKeyframe(source, 0), false, "two-point minimum");
+ disableSourceTrajectory(source);
+ assertEqual(source.trajectory, undefined, "motion disabled");
+ assertEqual(source.position.join(","), "-0.25,0.2,1.5", "fallback remains after disable");
+});
+
test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
const assets = [
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },