feat: edit moving source trajectories
This commit is contained in:
@@ -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 효과나 연속적인 전파 시간 변화는 현재 모델 범위에 포함되지 않습니다.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -133,6 +133,19 @@
|
||||
<label class="field"><span>시작 (s)</span><input id="source-start" type="number" min="0" step="0.01" /></label>
|
||||
<label class="field"><span>길이 (s)</span><input id="source-duration" type="number" min="0.01" step="0.01" /></label>
|
||||
</div>
|
||||
<section class="trajectory-editor" aria-label="음원 이동 경로">
|
||||
<label class="check-field trajectory-toggle"><input id="source-motion" type="checkbox" /><span>시간에 따라 음원 이동</span></label>
|
||||
<p class="section-note">경로 시간은 Scene 시작 기준입니다. 이동을 켜면 현재 위치에서 시작해 Scene 끝까지 이어지는 두 점 경로가 만들어집니다.</p>
|
||||
<div id="trajectory-fields" class="is-hidden">
|
||||
<label class="field field--wide trajectory-preview">
|
||||
<span>경로 미리보기 · <output id="preview-time-value" for="preview-time">0.00 s</output></span>
|
||||
<input id="preview-time" type="range" min="0" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<p class="section-note">경로점을 선택하면 canvas 클릭으로 그 점의 x/y를 옮길 수 있습니다. 흰 테두리 점은 현재 선택한 경로점입니다.</p>
|
||||
<div class="trajectory-list" id="trajectory-list"></div>
|
||||
<button class="button button--ghost button--full" id="add-keyframe-button" type="button">+ 경로점 추가</button>
|
||||
</div>
|
||||
</section>
|
||||
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
||||
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
|
||||
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user