feat: edit moving source trajectories
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user