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`: 주파수와 위상을 갖는 합성 정현파 - `Tone`: 주파수와 위상을 갖는 합성 정현파
- `Noise`: seed로 재현되는 white 또는 pink noise - `Noise`: seed로 재현되는 white 또는 pink noise
- `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능 - `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능
6. 필요하면 ambient 종류와 gain을 설정합니다. 6. 움직이는 음원은 **시간에 따라 음원 이동**을 켭니다. 현재 위치에서 시작해 Scene 끝까지 이어지는
7. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다. 기본 두 점 경로가 생성됩니다. 경로점의 절대 시간과 x/y/z를 수정하거나 점을 추가·삭제할 수 있습니다.
7. **경로 미리보기** slider로 시간별 위치를 확인합니다. 번호를 눌러 경로점을 선택한 뒤 canvas를
클릭하면 선택한 점의 x/y가 바뀝니다. 첫 경로점은 고정 장면 호환용 `source.position`에도 동기화됩니다.
8. 필요하면 ambient 종류와 gain을 설정합니다.
9. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
오디오 파일은 PCM WAV가 가장 안정적입니다. MP3, FLAC, OGG, M4A 등의 실제 decode 지원 여부는 오디오 파일은 PCM WAV가 가장 안정적입니다. MP3, FLAC, OGG, M4A 등의 실제 decode 지원 여부는
브라우저와 운영체제 codec에 따라 달라지며, 지원하지 않는 파일은 미리 듣기 시 오류로 표시됩니다. 브라우저와 운영체제 codec에 따라 달라지며, 지원하지 않는 파일은 미리 듣기 시 오류로 표시됩니다.
@@ -69,3 +73,7 @@ id, 종류, 파일명과 SHA-256이 들어갑니다.
Builder의 mix는 재현 가능한 source recipe와 위치 ground truth를 만드는 단계입니다. 자유음장 전파, Builder의 mix는 재현 가능한 source recipe와 위치 ground truth를 만드는 단계입니다. 자유음장 전파,
마이크 calibration과 DAS는 Acoustic Camera에서 수행합니다. 실제 방의 반사·잔향·차폐를 Builder가 마이크 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 { SOURCE_COLORS } from "./model.js";
import { sourcePositionAtTime } from "/packages/acoustics-core/trajectory.js";
function finitePlane(plane) { function finitePlane(plane) {
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) && 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"; context.textAlign = "left";
} }
function drawSource(context, source, index, selected, plane, width, height) { function drawTrajectory(context, source, index, selected, selectedKeyframeIndex, plane, width, height) {
if (!source.position?.every(Number.isFinite)) return; if (!Array.isArray(source.trajectory) || source.trajectory.length < 2) return;
const point = coordinateToPixel(source.position, plane, width, height); 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; if (point.x < -30 || point.x > width + 30 || point.y < -30 || point.y > height + 30) return;
const color = SOURCE_COLORS[index % SOURCE_COLORS.length]; const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save(); context.save();
@@ -81,8 +123,8 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
let current = null; let current = null;
function render(scene, image, plane, selectedSourceId) { function render(scene, image, plane, selectedSourceId, previewTime = 0, selectedKeyframeIndex = 0) {
current = { scene, image, plane, selectedSourceId }; current = { scene, image, plane, selectedSourceId, previewTime, selectedKeyframeIndex };
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const width = Math.max(1, rect.width); const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height); const height = Math.max(1, rect.height);
@@ -111,12 +153,22 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
context.fillRect(0, 0, width, height); context.fillRect(0, 0, width, height);
} }
scene.sources.forEach((source, index) => { 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)); emptyElement.classList.toggle("is-hidden", Boolean(image));
badgeElement.textContent = badgeElement.textContent =
`x ${plane.xMin.toFixed(2)}…${plane.xMax.toFixed(2)} m · ` + `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) => { canvas.addEventListener("pointerdown", (event) => {
@@ -128,7 +180,10 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
}); });
new ResizeObserver(() => { 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); }).observe(canvas);
return { render }; 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-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> <label class="field"><span>길이 (s)</span><input id="source-duration" type="number" min="0.01" step="0.01" /></label>
</div> </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> <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="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> <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 { createSceneCanvas } from "./canvas-view.js";
import { import {
DEFAULT_VIEW_PLANE, DEFAULT_VIEW_PLANE,
MAX_TRAJECTORY_KEYFRAMES,
SOURCE_COLORS, SOURCE_COLORS,
assetDescriptorForFile, assetDescriptorForFile,
changeSignalType, changeSignalType,
createSceneId, createSceneId,
createSource, createSource,
describeSignal, describeSignal,
disableSourceTrajectory,
enableSourceTrajectory,
insertTrajectoryKeyframe,
isAssetReferenced, isAssetReferenced,
matchAssetDescriptor, matchAssetDescriptor,
prepareSceneForEditing, prepareSceneForEditing,
removeTrajectoryKeyframe,
referencedAssetIds, referencedAssetIds,
safeDownloadStem, safeDownloadStem,
scaleSceneTrajectories,
synchronizeSourcePosition,
trajectoryTimeBounds,
updateTrajectoryKeyframeTime,
} from "./model.js"; } from "./model.js";
const byId = (id) => { 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", "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", "source-gain", "source-start", "source-duration", "source-type", "tone-fields", "tone-frequency",
"noise-fields", "noise-color", "asset-fields", "source-asset", "source-channel", "source-loop", "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", "remove-source-button", "ambient-type", "ambient-asset-field", "ambient-asset-options", "ambient-asset",
"ambient-channel", "ambient-loop", "ambient-gain", "ambient-channel", "ambient-loop", "ambient-gain",
"validation-dot", "validation-title", "validation-message", "save-state", "validation-dot", "validation-title", "validation-message", "save-state",
@@ -44,6 +54,8 @@ elements.validationCard = document.querySelector(".validation-card");
const state = { const state = {
scene: null, scene: null,
selectedSourceId: null, selectedSourceId: null,
selectedKeyframeIndex: 0,
previewTime: 0,
viewPlane: { ...DEFAULT_VIEW_PLANE }, viewPlane: { ...DEFAULT_VIEW_PLANE },
imageElement: null, imageElement: null,
imageUrl: null, imageUrl: null,
@@ -70,8 +82,10 @@ const canvasView = createSceneCanvas(
({ x, y }) => { ({ x, y }) => {
const source = selectedSource(); const source = selectedSource();
if (!source) return; if (!source) return;
source.position[0] = Number(x.toFixed(4)); const target = source.trajectory?.[state.selectedKeyframeIndex]?.position ?? source.position;
source.position[1] = Number(y.toFixed(4)); target[0] = Number(x.toFixed(4));
target[1] = Number(y.toFixed(4));
synchronizeSourcePosition(source);
syncSourceEditor(); syncSourceEditor();
markChanged("위치 변경됨"); markChanged("위치 변경됨");
renderSourceCards(); renderSourceCards();
@@ -95,6 +109,7 @@ function createAssetBackedSource(descriptor, file) {
changeSignalType(source, "asset", descriptor.id); changeSignalType(source, "asset", descriptor.id);
state.scene.sources.push(source); state.scene.sources.push(source);
state.selectedSourceId = source.id; state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
return source; return source;
} }
@@ -137,7 +152,10 @@ function syncImagePlane() {
function renderCanvas() { function renderCanvas() {
if (!state.scene) return; 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() { function renderSourceCards() {
@@ -159,10 +177,12 @@ function renderSourceCards() {
const signal = document.createElement("small"); const signal = document.createElement("small");
signal.textContent = describeSignal(source.signal); signal.textContent = describeSignal(source.signal);
const position = document.createElement("small"); 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.append(heading, signal, position);
button.addEventListener("click", () => { button.addEventListener("click", () => {
state.selectedSourceId = source.id; state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
renderSourceCards(); renderSourceCards();
syncSourceEditor(); syncSourceEditor();
renderCanvas(); renderCanvas();
@@ -193,6 +213,104 @@ function fillAudioSelect(select, selectedId, placeholder) {
select.value = selectedId ?? ""; 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() { function syncSourceEditor() {
const source = selectedSource(); const source = selectedSource();
elements["no-source-message"].classList.toggle("is-hidden", Boolean(source)); 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-start"], source.startTime);
setNumber(elements["source-duration"], source.duration); setNumber(elements["source-duration"], source.duration);
elements["source-type"].value = source.signal.type; elements["source-type"].value = source.signal.type;
renderTrajectoryEditor(source);
elements["tone-fields"].classList.toggle("is-hidden", source.signal.type !== "tone"); elements["tone-fields"].classList.toggle("is-hidden", source.signal.type !== "tone");
elements["noise-fields"].classList.toggle("is-hidden", source.signal.type !== "noise"); elements["noise-fields"].classList.toggle("is-hidden", source.signal.type !== "noise");
elements["asset-fields"].classList.toggle("is-hidden", source.signal.type !== "asset"); elements["asset-fields"].classList.toggle("is-hidden", source.signal.type !== "asset");
@@ -532,7 +651,15 @@ function bindSceneControls() {
elements["workspace-title"].textContent = input.value || "이름 없는 장면"; elements["workspace-title"].textContent = input.value || "이름 없는 장면";
}); });
bindInput("scene-id", (input) => { state.scene.id = 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) => { bindInput("sample-rate", (input) => {
state.scene.sampleRate = Number(input.value); state.scene.sampleRate = Number(input.value);
state.decodedAudio.clear(); state.decodedAudio.clear();
@@ -571,6 +698,7 @@ function bindSourceControls() {
const source = createSource(state.scene); const source = createSource(state.scene);
state.scene.sources.push(source); state.scene.sources.push(source);
state.selectedSourceId = source.id; state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
renderSourceCards(); renderSourceCards();
syncSourceEditor(); syncSourceEditor();
renderCanvas(); renderCanvas();
@@ -583,6 +711,7 @@ function bindSourceControls() {
} }
state.scene.sources = state.scene.sources.filter((source) => source.id !== state.selectedSourceId); state.scene.sources = state.scene.sources.filter((source) => source.id !== state.selectedSourceId);
state.selectedSourceId = state.scene.sources[0].id; state.selectedSourceId = state.scene.sources[0].id;
state.selectedKeyframeIndex = 0;
renderSourceCards(); renderSourceCards();
syncSourceEditor(); syncSourceEditor();
renderCanvas(); renderCanvas();
@@ -593,7 +722,12 @@ function bindSourceControls() {
for (const [id, index] of [["source-x", 0], ["source-y", 1], ["source-z", 2]]) { for (const [id, index] of [["source-x", 0], ["source-y", 1], ["source-z", 2]]) {
bindInput(id, (input) => { bindInput(id, (input) => {
selectedSource().position[index] = numberFrom(input); selectedSource().position[index] = numberFrom(input);
if (selectedSource().trajectory) {
selectedSource().trajectory[0].position[index] = numberFrom(input);
synchronizeSourcePosition(selectedSource());
}
renderSourceCards(); renderSourceCards();
renderTrajectoryEditor(selectedSource());
renderCanvas(); renderCanvas();
}); });
} }
@@ -611,6 +745,41 @@ function bindSourceControls() {
bindInput("source-asset", (input) => { selectedSource().signal.assetId = input.value; renderSourceCards(); }, "change"); bindInput("source-asset", (input) => { selectedSource().signal.assetId = input.value; renderSourceCards(); }, "change");
bindInput("source-channel", (input) => { selectedSource().signal.channel = numberFrom(input); }); bindInput("source-channel", (input) => { selectedSource().signal.channel = numberFrom(input); });
bindInput("source-loop", (input) => { selectedSource().signal.loop = input.checked; }, "change"); 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 } = {}) { async function handleAudioFiles(files, { createSources = false } = {}) {
@@ -784,6 +953,8 @@ function bindFileControls() {
state.assetErrors.clear(); state.assetErrors.clear();
state.scene = prepareSceneForEditing(candidate); state.scene = prepareSceneForEditing(candidate);
state.selectedSourceId = state.scene.sources[0].id; state.selectedSourceId = state.scene.sources[0].id;
state.selectedKeyframeIndex = 0;
state.previewTime = 0;
state.viewPlane = state.scene.image state.viewPlane = state.scene.image
? { ? {
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax, 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 = prepareSceneForEditing(await response.json());
state.scene.id = createSceneId(); state.scene.id = createSceneId();
state.selectedSourceId = state.scene.sources[0].id; state.selectedSourceId = state.scene.sources[0].id;
state.selectedKeyframeIndex = 0;
state.previewTime = 0;
if (state.scene.image) { if (state.scene.image) {
state.viewPlane = { state.viewPlane = {
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax, 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 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) { export function prepareSceneForEditing(scene) {
const prepared = structuredClone(scene); const prepared = structuredClone(scene);
prepared.assets ??= []; prepared.assets ??= [];
prepared.ambient ??= []; prepared.ambient ??= [];
prepared.mix ??= { normalization: "none", targetPeak: 0.95 }; prepared.mix ??= { normalization: "none", targetPeak: 0.95 };
prepared.sources.forEach(synchronizeSourcePosition);
return prepared; return prepared;
} }
+79
View File
@@ -458,6 +458,76 @@
line-height: 1.5; 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 { .is-hidden {
display: none !important; display: none !important;
} }
@@ -563,4 +633,13 @@
.field-grid--three { .field-grid--three {
grid-template-columns: 1fr; 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;
}
} }
+13
View File
@@ -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-x").value), 0.3, 0.002, "canvas x");
assertNear(Number(documentObject.querySelector("#source-y").value), 0.225, 0.002, "canvas y"); 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"); dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true); dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증"); await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
@@ -122,6 +132,9 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
const firstScene = JSON.parse(firstJsonText); const firstScene = JSON.parse(firstJsonText);
assertEqual(firstScene.sources.length, 2, "exported source count"); assertEqual(firstScene.sources.length, 2, "exported source count");
assertEqual(firstScene.sources[1].signal.loop, true, "source loop exported"); 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.ambient[0].signal.loop, true, "ambient loop exported");
assertEqual(firstScene.image.widthPixels, 640, "image width exported"); assertEqual(firstScene.image.widthPixels, 640, "image width exported");
assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported"); assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported");
+31
View File
@@ -1,11 +1,17 @@
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js"; import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
import { import {
assetDescriptorForFile, assetDescriptorForFile,
disableSourceTrajectory,
enableSourceTrajectory,
createSceneId, createSceneId,
createSource, createSource,
insertTrajectoryKeyframe,
matchAssetDescriptor, matchAssetDescriptor,
prepareSceneForEditing, prepareSceneForEditing,
removeTrajectoryKeyframe,
referencedAssetIds, referencedAssetIds,
scaleSceneTrajectories,
updateTrajectoryKeyframeTime,
} from "/apps/dataset-builder/model.js"; } from "/apps/dataset-builder/model.js";
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js"; import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
import { 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"); 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을 우선하고 충돌을 분리한다", () => { test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
const assets = [ const assets = [
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) }, { id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },