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
+178 -5
View File
@@ -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,