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 끝까지 이어지는 두 점 경로가 만들어집니다.

+ +
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) },