feat: edit moving source trajectories
This commit is contained in:
@@ -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 효과나 연속적인 전파 시간 변화는 현재 모델 범위에 포함되지 않습니다.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
Reference in New Issue
Block a user